add: service edit date + browser session ids

This commit is contained in:
trisua 2025-07-11 18:56:49 -04:00
parent 9aee80493f
commit cfcc2358f4
17 changed files with 148 additions and 29 deletions

View file

@ -140,6 +140,20 @@ macro_rules! get_user_from_token {
None None
} }
}}; }};
(--browser_session=$browser_session:expr, $db:expr) => {{
// browser session id
match $db.get_user_by_browser_session(&$browser_session).await {
Ok(ua) => {
if ua.permissions.check_banned() {
None
} else {
Some(ua)
}
}
Err(_) => None,
}
}};
} }
#[macro_export] #[macro_export]

View file

@ -123,7 +123,7 @@ async fn main() {
.merge(routes::routes(&config)) .merge(routes::routes(&config))
.layer(SetResponseHeaderLayer::if_not_present( .layer(SetResponseHeaderLayer::if_not_present(
HeaderName::from_static("content-security-policy"), HeaderName::from_static("content-security-policy"),
HeaderValue::from_static("default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; worker-src * blob:; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *; frame-ancestors 'self'"), HeaderValue::from_static("default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; worker-src * blob:; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' blob: *; frame-ancestors 'self'"),
)); ));
} }

View file

@ -55,7 +55,7 @@
(iframe (iframe
("id" "browser_iframe") ("id" "browser_iframe")
("frameborder" "0") ("frameborder" "0")
("src" "{% if path -%} {{ config.lw_host }}/api/v1/net/{{ path }} {%- endif %}")) ("src" "{% if path -%} {{ config.lw_host }}/api/v1/net/{{ path }}?s={{ session }} {%- endif %}"))
(style (style
("data-turbo-temporary" "true") ("data-turbo-temporary" "true")
@ -116,14 +116,15 @@
}")) }"))
(script (script
(text "function littleweb_navigate(uri) { (text "globalThis.SECRET_SESSION = \"{{ session }}\";
function littleweb_navigate(uri) {
if (!uri.includes(\".html\")) { if (!uri.includes(\".html\")) {
uri = `${uri}/index.html`; uri = `${uri}/index.html`;
} }
// ... // ...
console.log(\"navigate\", uri); console.log(\"navigate\", uri);
document.getElementById(\"browser_iframe\").src = `{{ config.lw_host|safe }}/api/v1/net/${uri}`; document.getElementById(\"browser_iframe\").src = `{{ config.lw_host|safe }}/api/v1/net/${uri}?s={{ session }}`;
} }
document.getElementById(\"browser_iframe\").addEventListener(\"load\", (e) => { document.getElementById(\"browser_iframe\").addEventListener(\"load\", (e) => {

View file

@ -73,7 +73,10 @@
(span (span
("class" "date") ("class" "date")
(text "{{ item.created }}")) (text "{{ item.created }}"))
(text "; {{ item.files|length }} files"))) (text "; Updated ")
(span
("class" "date")
(text "{{ item.revision }}"))))
(text "{% endfor %}")))) (text "{% endfor %}"))))
(script (script

View file

@ -54,7 +54,7 @@ function fix_atto_links() {
`atto://${path.replace("atto://", "").split("/")[0]}${x}`; `atto://${path.replace("atto://", "").split("/")[0]}${x}`;
} else { } else {
y[property] = y[property] =
`/api/v1/net/${path.replace("atto://", "").split("/")[0]}${x}`; `/api/v1/net/${path.replace("atto://", "").split("/")[0]}${x}?s=${globalThis.SECRET_SESSION}`;
} }
} }
} }
@ -112,7 +112,7 @@ function fix_atto_links() {
if (TETRATTO_LINK_HANDLER_CTX === "net") { if (TETRATTO_LINK_HANDLER_CTX === "net") {
window.location.href = `/net/${href.replace("atto://", "")}`; window.location.href = `/net/${href.replace("atto://", "")}`;
} else { } else {
window.location.href = `/api/v1/net/${href}`; window.location.href = `/api/v1/net/${href}?s=${globalThis.SECRET_SESSION}`;
} }
}); });

View file

@ -3,13 +3,21 @@ use crate::{
routes::api::v1::{CreateDomain, UpdateDomainData}, routes::api::v1::{CreateDomain, UpdateDomainData},
State, State,
}; };
use axum::{extract::Path, response::IntoResponse, http::StatusCode, Extension, Json}; use axum::{
extract::{Path, Query},
http::StatusCode,
response::IntoResponse,
Extension, Json,
};
use axum_extra::extract::CookieJar; use axum_extra::extract::CookieJar;
use tetratto_core::model::{ use tetratto_core::model::{
auth::AchievementName, auth::AchievementName,
littleweb::{Domain, ServiceFsMime}, littleweb::{Domain, ServiceFsMime},
oauth, ApiReturn, Error, oauth,
permissions::FinePermission,
ApiReturn, Error,
}; };
use serde::Deserialize;
pub async fn get_request( pub async fn get_request(
Path(id): Path<usize>, Path(id): Path<usize>,
@ -119,9 +127,16 @@ pub async fn delete_request(
} }
} }
#[derive(Deserialize)]
pub struct GetFileQuery {
#[serde(default, alias = "s")]
pub session: String,
}
pub async fn get_file_request( pub async fn get_file_request(
Path(mut addr): Path<String>, Path(mut addr): Path<String>,
Extension(data): Extension<State>, Extension(data): Extension<State>,
Query(props): Query<GetFileQuery>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if !addr.starts_with("atto://") { if !addr.starts_with("atto://") {
addr = format!("atto://{addr}"); addr = format!("atto://{addr}");
@ -129,8 +144,21 @@ pub async fn get_file_request(
// ... // ...
let data = &(data.read().await).0; let data = &(data.read().await).0;
let user = get_user_from_token!(--browser_session = props.session, data);
let (subdomain, domain, tld, path) = Domain::from_str(&addr); let (subdomain, domain, tld, path) = Domain::from_str(&addr);
if path.starts_with("$") && user.is_none() {
return Err((StatusCode::BAD_REQUEST, Error::NotAllowed.to_string()));
} else if let Some(ref ua) = user
&& path.starts_with("$paid")
&& !ua.permissions.check(FinePermission::SUPPORTER)
{
return Err((
StatusCode::BAD_REQUEST,
Error::RequiresSupporter.to_string(),
));
}
// resolve domain // resolve domain
let domain = match data.get_domain_by_name_tld(&domain, &tld).await { let domain = match data.get_domain_by_name_tld(&domain, &tld).await {
Ok(x) => x, Ok(x) => x,
@ -160,17 +188,28 @@ pub async fn get_file_request(
Some((f, _)) => Ok(( Some((f, _)) => Ok((
[("Content-Type".to_string(), f.mime.to_string())], [("Content-Type".to_string(), f.mime.to_string())],
if f.mime == ServiceFsMime::Html { if f.mime == ServiceFsMime::Html {
f.content.replace( f.content
"</body>", .replace(
&format!( "</body>",
"<script src=\"{}/js/proto_links.js\" defer></script></body>", &format!(
data.0.0.host "<script src=\"{}/js/proto_links.js\" defer></script><script>
), globalThis.SECRET_SESSION = \"{}\";
) </script></body>",
data.0.0.host, props.session
),
)
.replace(
".js\"",
&format!(".js?r={}&s={}\"", service.revision, props.session),
)
.replace(
".css\"",
&format!(".css?r={}&s={}\"", service.revision, props.session),
)
} else { } else {
f.content f.content
} }
.replace("atto://", "/api/v1/net/atto://"), .replace("atto://", "/api/v1/net/"),
)), )),
None => { None => {
return Err(( return Err((

View file

@ -8,6 +8,7 @@ use crate::{
use axum::{extract::Path, response::IntoResponse, Extension, Json}; use axum::{extract::Path, response::IntoResponse, Extension, Json};
use axum_extra::extract::CookieJar; use axum_extra::extract::CookieJar;
use tetratto_core::model::{auth::AchievementName, littleweb::Service, oauth, ApiReturn, Error}; use tetratto_core::model::{auth::AchievementName, littleweb::Service, oauth, ApiReturn, Error};
use tetratto_shared::unix_epoch_timestamp;
pub async fn get_request( pub async fn get_request(
Path(id): Path<usize>, Path(id): Path<usize>,
@ -156,11 +157,17 @@ pub async fn update_content_request(
// ... // ...
match data.update_service_files(id, &user, service.files).await { match data.update_service_files(id, &user, service.files).await {
Ok(_) => Json(ApiReturn { Ok(_) => match data
ok: true, .update_service_revision(id, unix_epoch_timestamp() as i64)
message: "Service updated".to_string(), .await
payload: (), {
}), Ok(_) => Json(ApiReturn {
ok: true,
message: "Service updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
},
Err(e) => Json(e.into()), Err(e) => Json(e.into()),
} }
} }

View file

@ -365,7 +365,7 @@ pub async fn global_view_request(
Ok(( Ok((
[( [(
"content-security-policy", "content-security-policy",
"default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; worker-src * blob:; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *; frame-ancestors *", "default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; worker-src * blob:; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' blob: *; frame-ancestors *",
)], )],
Html(data.1.render("journals/app.html", &context).unwrap()), Html(data.1.render("journals/app.html", &context).unwrap()),
)) ))

View file

@ -11,6 +11,7 @@ use axum::{
use axum_extra::extract::CookieJar; use axum_extra::extract::CookieJar;
use tetratto_core::model::{littleweb::TLDS_VEC, permissions::SecondaryPermission, Error}; use tetratto_core::model::{littleweb::TLDS_VEC, permissions::SecondaryPermission, Error};
use serde::Deserialize; use serde::Deserialize;
use tetratto_shared::hash::salt;
/// `/services` /// `/services`
pub async fn services_request( pub async fn services_request(
@ -230,12 +231,26 @@ pub async fn browser_home_request(
let data = data.read().await; let data = data.read().await;
let user = get_user_from_token!(jar, data.0); let user = get_user_from_token!(jar, data.0);
// update session
let session = salt();
if let Some(ref ua) = user {
if let Err(e) = data.0.update_user_browser_session(ua.id, &session).await {
return Err(Html(render_error(e.into(), &jar, &data, &None).await));
}
}
// ...
let lang = get_lang!(jar, data.0); let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &user).await; let mut context = initial_context(&data.0.0.0, lang, &user).await;
context.insert("path", &""); context.insert("path", &"");
context.insert("session", &session);
// return // return
Html(data.1.render("littleweb/browser.html", &context).unwrap()) Ok(Html(
data.1.render("littleweb/browser.html", &context).unwrap(),
))
} }
/// `/net/{uri}` /// `/net/{uri}`
@ -255,10 +270,24 @@ pub async fn browser_request(
uri = format!("atto://{uri}"); uri = format!("atto://{uri}");
} }
// update session
let session = salt();
if let Some(ref ua) = user {
if let Err(e) = data.0.update_user_browser_session(ua.id, &session).await {
return Err(Html(render_error(e.into(), &jar, &data, &None).await));
}
}
// ...
let lang = get_lang!(jar, data.0); let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &user).await; let mut context = initial_context(&data.0.0.0, lang, &user).await;
context.insert("session", &session);
context.insert("path", &uri.replace("atto://", "")); context.insert("path", &uri.replace("atto://", ""));
// return // return
Html(data.1.render("littleweb/browser.html", &context).unwrap()) Ok(Html(
data.1.render("littleweb/browser.html", &context).unwrap(),
))
} }

View file

@ -116,12 +116,14 @@ impl DataManager {
achievements: serde_json::from_str(&get!(x->23(String)).to_string()).unwrap(), achievements: serde_json::from_str(&get!(x->23(String)).to_string()).unwrap(),
awaiting_purchase: get!(x->24(i32)) as i8 == 1, awaiting_purchase: get!(x->24(i32)) as i8 == 1,
was_purchased: get!(x->25(i32)) as i8 == 1, was_purchased: get!(x->25(i32)) as i8 == 1,
browser_session: get!(x->26(String)),
} }
} }
auto_method!(get_user_by_id(usize as i64)@get_user_from_row -> "SELECT * FROM users WHERE id = $1" --name="user" --returns=User --cache-key-tmpl="atto.user:{}"); auto_method!(get_user_by_id(usize as i64)@get_user_from_row -> "SELECT * FROM users WHERE id = $1" --name="user" --returns=User --cache-key-tmpl="atto.user:{}");
auto_method!(get_user_by_username(&str)@get_user_from_row -> "SELECT * FROM users WHERE username = $1" --name="user" --returns=User --cache-key-tmpl="atto.user:{}"); auto_method!(get_user_by_username(&str)@get_user_from_row -> "SELECT * FROM users WHERE username = $1" --name="user" --returns=User --cache-key-tmpl="atto.user:{}");
auto_method!(get_user_by_username_no_cache(&str)@get_user_from_row -> "SELECT * FROM users WHERE username = $1" --name="user" --returns=User); auto_method!(get_user_by_username_no_cache(&str)@get_user_from_row -> "SELECT * FROM users WHERE username = $1" --name="user" --returns=User);
auto_method!(get_user_by_browser_session(&str)@get_user_from_row -> "SELECT * FROM users WHERE browser_session = $1" --name="user" --returns=User);
/// Get a user given just their ID. Returns the void user if the user doesn't exist. /// Get a user given just their ID. Returns the void user if the user doesn't exist.
/// ///
@ -271,7 +273,7 @@ impl DataManager {
let res = execute!( let res = execute!(
&conn, &conn,
"INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26)", "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27)",
params![ params![
&(data.id as i64), &(data.id as i64),
&(data.created as i64), &(data.created as i64),
@ -299,6 +301,7 @@ impl DataManager {
&serde_json::to_string(&data.achievements).unwrap(), &serde_json::to_string(&data.achievements).unwrap(),
&if data.awaiting_purchase { 1_i32 } else { 0_i32 }, &if data.awaiting_purchase { 1_i32 } else { 0_i32 },
&if data.was_purchased { 1_i32 } else { 0_i32 }, &if data.was_purchased { 1_i32 } else { 0_i32 },
&data.browser_session,
] ]
); );
@ -993,6 +996,7 @@ impl DataManager {
auto_method!(update_user_associated(Vec<usize>)@get_user_by_id -> "UPDATE users SET associated = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_associated(Vec<usize>)@get_user_by_id -> "UPDATE users SET associated = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_achievements(Vec<Achievement>)@get_user_by_id -> "UPDATE users SET achievements = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_achievements(Vec<Achievement>)@get_user_by_id -> "UPDATE users SET achievements = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_invite_code(i64)@get_user_by_id -> "UPDATE users SET invite_code = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); auto_method!(update_user_invite_code(i64)@get_user_by_id -> "UPDATE users SET invite_code = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_browser_session(&str)@get_user_by_id -> "UPDATE users SET browser_session = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);
auto_method!(get_user_by_stripe_id(&str)@get_user_from_row -> "SELECT * FROM users WHERE stripe_id = $1" --name="user" --returns=User); auto_method!(get_user_by_stripe_id(&str)@get_user_from_row -> "SELECT * FROM users WHERE stripe_id = $1" --name="user" --returns=User);
auto_method!(update_user_stripe_id(&str)@get_user_by_id -> "UPDATE users SET stripe_id = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); auto_method!(update_user_stripe_id(&str)@get_user_by_id -> "UPDATE users SET stripe_id = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);

View file

@ -3,5 +3,6 @@ CREATE TABLE IF NOT EXISTS services (
created BIGINT NOT NULL, created BIGINT NOT NULL,
owner BIGINT NOT NULL, owner BIGINT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
files TEXT NOT NULL files TEXT NOT NULL,
revision BIGINT NOT NULL
) )

View file

@ -23,5 +23,6 @@ CREATE TABLE IF NOT EXISTS users (
secondary_permissions INT NOT NULL, secondary_permissions INT NOT NULL,
achievements TEXT NOT NULL, achievements TEXT NOT NULL,
awaiting_purchase INT NOT NULL, awaiting_purchase INT NOT NULL,
was_purchased INT NOT NULL was_purchased INT NOT NULL,
browser_session TEXT NOT NULL
) )

View file

@ -16,6 +16,7 @@ impl DataManager {
owner: get!(x->2(i64)) as usize, owner: get!(x->2(i64)) as usize,
name: get!(x->3(String)), name: get!(x->3(String)),
files: serde_json::from_str(&get!(x->4(String))).unwrap(), files: serde_json::from_str(&get!(x->4(String))).unwrap(),
revision: get!(x->5(i64)) as usize,
} }
} }
@ -80,13 +81,14 @@ impl DataManager {
let res = execute!( let res = execute!(
&conn, &conn,
"INSERT INTO services VALUES ($1, $2, $3, $4, $5)", "INSERT INTO services VALUES ($1, $2, $3, $4, $5, $6)",
params![ params![
&(data.id as i64), &(data.id as i64),
&(data.created as i64), &(data.created as i64),
&(data.owner as i64), &(data.owner as i64),
&data.name, &data.name,
&serde_json::to_string(&data.files).unwrap(), &serde_json::to_string(&data.files).unwrap(),
&(data.created as i64)
] ]
); );
@ -128,4 +130,5 @@ impl DataManager {
auto_method!(update_service_name(&str)@get_service_by_id:FinePermission::MANAGE_USERS; -> "UPDATE services SET name = $1 WHERE id = $2" --cache-key-tmpl="atto.service:{}"); auto_method!(update_service_name(&str)@get_service_by_id:FinePermission::MANAGE_USERS; -> "UPDATE services SET name = $1 WHERE id = $2" --cache-key-tmpl="atto.service:{}");
auto_method!(update_service_files(Vec<ServiceFsEntry>)@get_service_by_id:FinePermission::MANAGE_USERS; -> "UPDATE services SET files = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.service:{}"); auto_method!(update_service_files(Vec<ServiceFsEntry>)@get_service_by_id:FinePermission::MANAGE_USERS; -> "UPDATE services SET files = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.service:{}");
auto_method!(update_service_revision(i64) -> "UPDATE services SET revision = $1 WHERE id = $2" --cache-key-tmpl="atto.service:{}");
} }

View file

@ -70,6 +70,16 @@ pub struct User {
/// used an invite code. /// used an invite code.
#[serde(default)] #[serde(default)]
pub was_purchased: bool, pub was_purchased: bool,
/// This value is updated for every **new** littleweb browser session.
///
/// This means the user can only have one of these sessions open at once
/// (unless this token is stored somewhere with a way to say we already have one,
/// but this does not happen yet).
///
/// Without this token, the user can still use the browser, they just cannot
/// view pages which require authentication (all `$` routes).
#[serde(default)]
pub browser_session: String,
} }
pub type UserConnections = pub type UserConnections =
@ -357,6 +367,7 @@ impl User {
achievements: Vec::new(), achievements: Vec::new(),
awaiting_purchase: false, awaiting_purchase: false,
was_purchased: false, was_purchased: false,
browser_session: String::new(),
} }
} }

View file

@ -11,6 +11,7 @@ pub struct Service {
pub owner: usize, pub owner: usize,
pub name: String, pub name: String,
pub files: Vec<ServiceFsEntry>, pub files: Vec<ServiceFsEntry>,
pub revision: usize,
} }
impl Service { impl Service {
@ -22,6 +23,7 @@ impl Service {
owner, owner,
name, name,
files: Vec::new(), files: Vec::new(),
revision: unix_epoch_timestamp(),
} }
} }

View file

@ -0,0 +1,2 @@
ALTER TABLE users
ADD COLUMN browser_session TEXT NOT NULL DEFAULT '';

View file

@ -0,0 +1,2 @@
ALTER TABLE services
ADD COLUMN revision BIGINT NOT NULL DEFAULT 0;