From cfcc2358f40f0a426bf4bc9c9f155f9db2f5390b Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 11 Jul 2025 18:56:49 -0400 Subject: [PATCH] add: service edit date + browser session ids --- crates/app/src/macros.rs | 14 +++++ crates/app/src/main.rs | 2 +- .../src/public/html/littleweb/browser.lisp | 7 ++- .../src/public/html/littleweb/services.lisp | 5 +- crates/app/src/public/js/proto_links.js | 4 +- crates/app/src/routes/api/v1/domains.rs | 59 +++++++++++++++---- crates/app/src/routes/api/v1/services.rs | 17 ++++-- crates/app/src/routes/pages/journals.rs | 2 +- crates/app/src/routes/pages/littleweb.rs | 33 ++++++++++- crates/core/src/database/auth.rs | 6 +- .../database/drivers/sql/create_services.sql | 3 +- .../src/database/drivers/sql/create_users.sql | 3 +- crates/core/src/database/services.rs | 5 +- crates/core/src/model/auth.rs | 11 ++++ crates/core/src/model/littleweb.rs | 2 + sql_changes/browser_session.sql | 2 + sql_changes/services_revision.sql | 2 + 17 files changed, 148 insertions(+), 29 deletions(-) create mode 100644 sql_changes/browser_session.sql create mode 100644 sql_changes/services_revision.sql diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index 2c3c03c..2f5433d 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -140,6 +140,20 @@ macro_rules! get_user_from_token { 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] diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 00ad85f..b4ffbe6 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -123,7 +123,7 @@ async fn main() { .merge(routes::routes(&config)) .layer(SetResponseHeaderLayer::if_not_present( 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'"), )); } diff --git a/crates/app/src/public/html/littleweb/browser.lisp b/crates/app/src/public/html/littleweb/browser.lisp index 8e298e2..67c64ab 100644 --- a/crates/app/src/public/html/littleweb/browser.lisp +++ b/crates/app/src/public/html/littleweb/browser.lisp @@ -55,7 +55,7 @@ (iframe ("id" "browser_iframe") ("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 ("data-turbo-temporary" "true") @@ -116,14 +116,15 @@ }")) (script - (text "function littleweb_navigate(uri) { + (text "globalThis.SECRET_SESSION = \"{{ session }}\"; + function littleweb_navigate(uri) { if (!uri.includes(\".html\")) { uri = `${uri}/index.html`; } // ... 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) => { diff --git a/crates/app/src/public/html/littleweb/services.lisp b/crates/app/src/public/html/littleweb/services.lisp index 83a6179..3399685 100644 --- a/crates/app/src/public/html/littleweb/services.lisp +++ b/crates/app/src/public/html/littleweb/services.lisp @@ -73,7 +73,10 @@ (span ("class" "date") (text "{{ item.created }}")) - (text "; {{ item.files|length }} files"))) + (text "; Updated ") + (span + ("class" "date") + (text "{{ item.revision }}")))) (text "{% endfor %}")))) (script diff --git a/crates/app/src/public/js/proto_links.js b/crates/app/src/public/js/proto_links.js index 87986b3..9cf4940 100644 --- a/crates/app/src/public/js/proto_links.js +++ b/crates/app/src/public/js/proto_links.js @@ -54,7 +54,7 @@ function fix_atto_links() { `atto://${path.replace("atto://", "").split("/")[0]}${x}`; } else { 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") { window.location.href = `/net/${href.replace("atto://", "")}`; } else { - window.location.href = `/api/v1/net/${href}`; + window.location.href = `/api/v1/net/${href}?s=${globalThis.SECRET_SESSION}`; } }); diff --git a/crates/app/src/routes/api/v1/domains.rs b/crates/app/src/routes/api/v1/domains.rs index 40ff713..f1af2e6 100644 --- a/crates/app/src/routes/api/v1/domains.rs +++ b/crates/app/src/routes/api/v1/domains.rs @@ -3,13 +3,21 @@ use crate::{ routes::api::v1::{CreateDomain, UpdateDomainData}, 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 tetratto_core::model::{ auth::AchievementName, littleweb::{Domain, ServiceFsMime}, - oauth, ApiReturn, Error, + oauth, + permissions::FinePermission, + ApiReturn, Error, }; +use serde::Deserialize; pub async fn get_request( Path(id): Path, @@ -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( Path(mut addr): Path, Extension(data): Extension, + Query(props): Query, ) -> impl IntoResponse { if !addr.starts_with("atto://") { addr = format!("atto://{addr}"); @@ -129,8 +144,21 @@ pub async fn get_file_request( // ... 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); + 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 let domain = match data.get_domain_by_name_tld(&domain, &tld).await { Ok(x) => x, @@ -160,17 +188,28 @@ pub async fn get_file_request( Some((f, _)) => Ok(( [("Content-Type".to_string(), f.mime.to_string())], if f.mime == ServiceFsMime::Html { - f.content.replace( - "", - &format!( - "", - data.0.0.host - ), - ) + f.content + .replace( + "", + &format!( + "", + 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 { f.content } - .replace("atto://", "/api/v1/net/atto://"), + .replace("atto://", "/api/v1/net/"), )), None => { return Err(( diff --git a/crates/app/src/routes/api/v1/services.rs b/crates/app/src/routes/api/v1/services.rs index d1ffbf0..a847338 100644 --- a/crates/app/src/routes/api/v1/services.rs +++ b/crates/app/src/routes/api/v1/services.rs @@ -8,6 +8,7 @@ use crate::{ use axum::{extract::Path, response::IntoResponse, Extension, Json}; use axum_extra::extract::CookieJar; use tetratto_core::model::{auth::AchievementName, littleweb::Service, oauth, ApiReturn, Error}; +use tetratto_shared::unix_epoch_timestamp; pub async fn get_request( Path(id): Path, @@ -156,11 +157,17 @@ pub async fn update_content_request( // ... match data.update_service_files(id, &user, service.files).await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Service updated".to_string(), - payload: (), - }), + Ok(_) => match data + .update_service_revision(id, unix_epoch_timestamp() as i64) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Service updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + }, Err(e) => Json(e.into()), } } diff --git a/crates/app/src/routes/pages/journals.rs b/crates/app/src/routes/pages/journals.rs index cdfba32..db76e93 100644 --- a/crates/app/src/routes/pages/journals.rs +++ b/crates/app/src/routes/pages/journals.rs @@ -365,7 +365,7 @@ pub async fn global_view_request( Ok(( [( "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()), )) diff --git a/crates/app/src/routes/pages/littleweb.rs b/crates/app/src/routes/pages/littleweb.rs index 61560d7..9e347e1 100644 --- a/crates/app/src/routes/pages/littleweb.rs +++ b/crates/app/src/routes/pages/littleweb.rs @@ -11,6 +11,7 @@ use axum::{ use axum_extra::extract::CookieJar; use tetratto_core::model::{littleweb::TLDS_VEC, permissions::SecondaryPermission, Error}; use serde::Deserialize; +use tetratto_shared::hash::salt; /// `/services` pub async fn services_request( @@ -230,12 +231,26 @@ pub async fn browser_home_request( let data = data.read().await; 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 mut context = initial_context(&data.0.0.0, lang, &user).await; + context.insert("path", &""); + context.insert("session", &session); // return - Html(data.1.render("littleweb/browser.html", &context).unwrap()) + Ok(Html( + data.1.render("littleweb/browser.html", &context).unwrap(), + )) } /// `/net/{uri}` @@ -255,10 +270,24 @@ pub async fn browser_request( 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 mut context = initial_context(&data.0.0.0, lang, &user).await; + + context.insert("session", &session); context.insert("path", &uri.replace("atto://", "")); // return - Html(data.1.render("littleweb/browser.html", &context).unwrap()) + Ok(Html( + data.1.render("littleweb/browser.html", &context).unwrap(), + )) } diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index fbf229b..88ef32e 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -116,12 +116,14 @@ impl DataManager { achievements: serde_json::from_str(&get!(x->23(String)).to_string()).unwrap(), awaiting_purchase: get!(x->24(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_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_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. /// @@ -271,7 +273,7 @@ impl DataManager { let res = execute!( &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![ &(data.id as i64), &(data.created as i64), @@ -299,6 +301,7 @@ impl DataManager { &serde_json::to_string(&data.achievements).unwrap(), &if data.awaiting_purchase { 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)@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)@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_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!(update_user_stripe_id(&str)@get_user_by_id -> "UPDATE users SET stripe_id = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); diff --git a/crates/core/src/database/drivers/sql/create_services.sql b/crates/core/src/database/drivers/sql/create_services.sql index 78277b5..ecb04d6 100644 --- a/crates/core/src/database/drivers/sql/create_services.sql +++ b/crates/core/src/database/drivers/sql/create_services.sql @@ -3,5 +3,6 @@ CREATE TABLE IF NOT EXISTS services ( created BIGINT NOT NULL, owner BIGINT NOT NULL, name TEXT NOT NULL, - files TEXT NOT NULL + files TEXT NOT NULL, + revision BIGINT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/create_users.sql b/crates/core/src/database/drivers/sql/create_users.sql index 3257a2d..0e24753 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -23,5 +23,6 @@ CREATE TABLE IF NOT EXISTS users ( secondary_permissions INT NOT NULL, achievements TEXT NOT NULL, awaiting_purchase INT NOT NULL, - was_purchased INT NOT NULL + was_purchased INT NOT NULL, + browser_session TEXT NOT NULL ) diff --git a/crates/core/src/database/services.rs b/crates/core/src/database/services.rs index f28460d..adc9bc6 100644 --- a/crates/core/src/database/services.rs +++ b/crates/core/src/database/services.rs @@ -16,6 +16,7 @@ impl DataManager { owner: get!(x->2(i64)) as usize, name: get!(x->3(String)), 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!( &conn, - "INSERT INTO services VALUES ($1, $2, $3, $4, $5)", + "INSERT INTO services VALUES ($1, $2, $3, $4, $5, $6)", params![ &(data.id as i64), &(data.created as i64), &(data.owner as i64), &data.name, &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_files(Vec)@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:{}"); } diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index c3d7de9..efea59a 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -70,6 +70,16 @@ pub struct User { /// used an invite code. #[serde(default)] 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 = @@ -357,6 +367,7 @@ impl User { achievements: Vec::new(), awaiting_purchase: false, was_purchased: false, + browser_session: String::new(), } } diff --git a/crates/core/src/model/littleweb.rs b/crates/core/src/model/littleweb.rs index 4c85024..f06154d 100644 --- a/crates/core/src/model/littleweb.rs +++ b/crates/core/src/model/littleweb.rs @@ -11,6 +11,7 @@ pub struct Service { pub owner: usize, pub name: String, pub files: Vec, + pub revision: usize, } impl Service { @@ -22,6 +23,7 @@ impl Service { owner, name, files: Vec::new(), + revision: unix_epoch_timestamp(), } } diff --git a/sql_changes/browser_session.sql b/sql_changes/browser_session.sql new file mode 100644 index 0000000..07570cd --- /dev/null +++ b/sql_changes/browser_session.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN browser_session TEXT NOT NULL DEFAULT ''; diff --git a/sql_changes/services_revision.sql b/sql_changes/services_revision.sql new file mode 100644 index 0000000..93150f0 --- /dev/null +++ b/sql_changes/services_revision.sql @@ -0,0 +1,2 @@ +ALTER TABLE services +ADD COLUMN revision BIGINT NOT NULL DEFAULT 0;