multi mode
This commit is contained in:
Generated
+1
-1
@@ -2581,7 +2581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustpad"
|
||||
version = "0.2.8"
|
||||
version = "0.2.9"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"aws-config",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustpad"
|
||||
version = "0.2.8"
|
||||
version = "0.2.9"
|
||||
edition = "2024"
|
||||
rust-version = "1.94"
|
||||
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
||||
|
||||
@@ -22,7 +22,6 @@ server {
|
||||
return 404 "Not Found\n";
|
||||
}
|
||||
|
||||
# Wszystko poza /f/ nie trafia do RustPada
|
||||
location / {
|
||||
default_type text/plain;
|
||||
return 404 "Not Found\n";
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
frontend http_front
|
||||
bind *:443 ssl crt /etc/haproxy/certs/example.pem
|
||||
mode http
|
||||
|
||||
acl rustpad_path path_beg /f/
|
||||
|
||||
use_backend rustpad_backend if rustpad_path
|
||||
|
||||
http-request return status 404 \
|
||||
content-type "text/plain" \
|
||||
string "Not Found" unless rustpad_path
|
||||
|
||||
|
||||
backend rustpad_backend
|
||||
mode http
|
||||
|
||||
http-request set-header X-Forwarded-Proto https
|
||||
http-request del-header Cookie
|
||||
http-request del-header Authorization
|
||||
|
||||
http-response del-header Set-Cookie
|
||||
http-response return status 404 \
|
||||
content-type "text/plain" \
|
||||
string "Not Found" if { status 404 }
|
||||
|
||||
server rustpad rustpad:3000 check
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE users
|
||||
ADD COLUMN theme VARCHAR(5) NOT NULL DEFAULT 'dark',
|
||||
ADD CONSTRAINT chk_users_theme CHECK (theme IN ('dark', 'light'));
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE users
|
||||
ADD COLUMN theme TEXT NOT NULL DEFAULT 'dark'
|
||||
CHECK (theme IN ('dark', 'light'));
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE users
|
||||
ADD COLUMN theme TEXT NOT NULL DEFAULT 'dark'
|
||||
CHECK (theme IN ('dark', 'light'));
|
||||
@@ -282,6 +282,7 @@ pub(super) fn error_response(
|
||||
asset_version: &str,
|
||||
) -> Response {
|
||||
let html = include_str!("../../static/error.html")
|
||||
.replace("__APP_THEME_BOOTSTRAP__", assets::theme_bootstrap())
|
||||
.replace(
|
||||
"__APP_STYLESHEET__",
|
||||
&assets::stylesheet_tag(asset_version, "styles"),
|
||||
|
||||
@@ -31,6 +31,7 @@ const MODULES: &[&str] = &[
|
||||
"session",
|
||||
"socket",
|
||||
"toast",
|
||||
"theme",
|
||||
"url-state",
|
||||
"security",
|
||||
];
|
||||
@@ -47,6 +48,7 @@ pub fn render_html(
|
||||
let urls = AssetUrls::new(asset_version);
|
||||
let frontend_config = frontend_config(frontend_log_level, upload_max_size_bytes, external_auth);
|
||||
let html = template
|
||||
.replace("__APP_THEME_BOOTSTRAP__", theme_bootstrap())
|
||||
.replace("__APP_STYLESHEET__", &urls.stylesheet("styles"))
|
||||
.replace("__APP_IMPORT_MAP__", &urls.import_map())
|
||||
.replace("__APP_ENTRYPOINT__", &urls.entrypoint(entrypoint))
|
||||
@@ -68,6 +70,10 @@ pub fn render_html(
|
||||
response
|
||||
}
|
||||
|
||||
pub fn theme_bootstrap() -> &'static str {
|
||||
r#"<script>(()=>{const key="rustpad:theme";let theme="dark";try{const saved=localStorage.getItem(key);if(saved==="light"||saved==="dark")theme=saved}catch{}const root=document.documentElement;root.dataset.theme=theme;root.style.colorScheme=theme;const meta=document.querySelector('meta[name="color-scheme"]');if(meta)meta.content=theme})();</script>"#
|
||||
}
|
||||
|
||||
pub fn stylesheet_tag(asset_version: &str, name: &str) -> String {
|
||||
AssetUrls::new(asset_version).stylesheet(name)
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ pub struct User {
|
||||
pub password_hash: String,
|
||||
pub confirmed_at: Option<String>,
|
||||
pub is_active: i64,
|
||||
pub theme: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -95,6 +96,8 @@ pub struct ProfileUpdateRequest {
|
||||
password: String,
|
||||
#[serde(default)]
|
||||
editor_color: Option<String>,
|
||||
#[serde(default)]
|
||||
theme: Option<String>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteAccountRequest {
|
||||
@@ -190,6 +193,7 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for User {
|
||||
password_hash: crate::row_decode::text(row, "password_hash")?,
|
||||
confirmed_at: crate::row_decode::optional_text(row, "confirmed_at")?,
|
||||
is_active: row.try_get("is_active")?,
|
||||
theme: crate::row_decode::text(row, "theme")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -268,6 +272,7 @@ pub struct SessionResponse {
|
||||
directory_organization: Option<String>,
|
||||
suggested_nickname: Option<String>,
|
||||
editor_color: Option<String>,
|
||||
theme: String,
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
pub struct IdentityResponse {
|
||||
@@ -282,6 +287,7 @@ pub struct RegisterResponse {
|
||||
email: String,
|
||||
expires_at: Option<String>,
|
||||
confirmation_required: bool,
|
||||
theme: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
@@ -433,6 +439,7 @@ pub async fn register(
|
||||
email: user.email,
|
||||
expires_at: None,
|
||||
confirmation_required: true,
|
||||
theme: user.theme,
|
||||
message:
|
||||
"Account created. Check your e-mail and confirm the account before logging in."
|
||||
.into(),
|
||||
@@ -451,6 +458,7 @@ pub async fn register(
|
||||
email: session.email,
|
||||
expires_at: Some(session.expires_at),
|
||||
confirmation_required: false,
|
||||
theme: session.theme,
|
||||
message: "Account created.".into(),
|
||||
}),
|
||||
).into_response();
|
||||
@@ -740,6 +748,7 @@ pub async fn me(
|
||||
directory_organization,
|
||||
suggested_nickname,
|
||||
editor_color,
|
||||
theme: user.theme,
|
||||
},
|
||||
state.user_session_ttl_days,
|
||||
))
|
||||
@@ -855,10 +864,23 @@ pub async fn update_profile(
|
||||
.map_err(AuthError::database)?;
|
||||
}
|
||||
|
||||
let mut theme = user.theme.clone();
|
||||
if let Some(value) = req.theme.as_deref() {
|
||||
theme = validate_theme(value)?.to_owned();
|
||||
sqlx::query(queries::get(state.db.kind(), queries::AUTH_UPDATE_THEME))
|
||||
.bind(&theme)
|
||||
.bind(Utc::now().to_rfc3339())
|
||||
.bind(user.id)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"nickname": nickname,
|
||||
"editor_color": req.editor_color.as_deref(),
|
||||
"theme": theme,
|
||||
"email_pending": email_pending,
|
||||
"message": if email_pending {
|
||||
"Profile updated. Confirm the new e-mail address using the link sent to it."
|
||||
@@ -2052,6 +2074,7 @@ async fn create_session(state: &SharedState, user: &User) -> Result<SessionRespo
|
||||
directory_organization,
|
||||
suggested_nickname,
|
||||
editor_color,
|
||||
theme: user.theme.clone(),
|
||||
})
|
||||
}
|
||||
async fn find_user_by_nickname(
|
||||
@@ -2104,6 +2127,14 @@ async fn find_user_by_email(state: &SharedState, email: &str) -> Result<Option<U
|
||||
.await
|
||||
.map_err(AuthError::database)
|
||||
}
|
||||
fn validate_theme(value: &str) -> Result<&str, AuthError> {
|
||||
match value.trim() {
|
||||
"dark" => Ok("dark"),
|
||||
"light" => Ok("light"),
|
||||
_ => Err(AuthError::bad_request("Unknown interface theme.")),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_editor_color(value: &str) -> Result<String, AuthError> {
|
||||
let value = value.trim();
|
||||
if value.len() == 7
|
||||
|
||||
@@ -26,6 +26,7 @@ pub enum Query {
|
||||
AUTH_LATEST_CONFIRMATION_CREATED_AT,
|
||||
AUTH_UPDATE_NICKNAME,
|
||||
AUTH_UPDATE_EDITOR_COLOR,
|
||||
AUTH_UPDATE_THEME,
|
||||
AUTH_EDITOR_COLOR_BY_USER,
|
||||
RESOURCE_COLOR_BY_USER,
|
||||
RESOURCE_COLOR_DELETE,
|
||||
@@ -176,6 +177,7 @@ pub const POSTGRES_PAD_REVISION_LAST_INSERT_ID: Query = Query::POSTGRES_PAD_REVI
|
||||
pub const AUTH_LATEST_CONFIRMATION_CREATED_AT: Query = Query::AUTH_LATEST_CONFIRMATION_CREATED_AT;
|
||||
pub const AUTH_UPDATE_NICKNAME: Query = Query::AUTH_UPDATE_NICKNAME;
|
||||
pub const AUTH_UPDATE_EDITOR_COLOR: Query = Query::AUTH_UPDATE_EDITOR_COLOR;
|
||||
pub const AUTH_UPDATE_THEME: Query = Query::AUTH_UPDATE_THEME;
|
||||
pub const AUTH_EDITOR_COLOR_BY_USER: Query = Query::AUTH_EDITOR_COLOR_BY_USER;
|
||||
pub const RESOURCE_COLOR_BY_USER: Query = Query::RESOURCE_COLOR_BY_USER;
|
||||
pub const RESOURCE_COLOR_DELETE: Query = Query::RESOURCE_COLOR_DELETE;
|
||||
|
||||
@@ -31,6 +31,9 @@ pub fn get(query: Query) -> &'static str {
|
||||
Query::AUTH_UPDATE_EDITOR_COLOR => {
|
||||
r#"UPDATE users SET editor_color = ?, updated_at = ? WHERE id = ?"#
|
||||
}
|
||||
Query::AUTH_UPDATE_THEME => {
|
||||
r#"UPDATE users SET theme = ?, updated_at = ? WHERE id = ?"#
|
||||
}
|
||||
Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = ?"#,
|
||||
Query::RESOURCE_COLOR_BY_USER => {
|
||||
r#"SELECT color FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"#
|
||||
@@ -103,7 +106,7 @@ pub fn get(query: Query) -> &'static str {
|
||||
r#"SELECT auth_provider, directory_display_name FROM users WHERE id = ?"#
|
||||
}
|
||||
Query::AUTH_USER_BY_EXTERNAL_ID => {
|
||||
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM users WHERE auth_provider = ? AND external_id = ?"#
|
||||
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(theme AS CHAR CHARACTER SET utf8mb4) AS theme FROM users WHERE auth_provider = ? AND external_id = ?"#
|
||||
}
|
||||
Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = ?"#,
|
||||
Query::AUTH_ANONYMIZE_USER => {
|
||||
@@ -150,19 +153,19 @@ pub fn get(query: Query) -> &'static str {
|
||||
}
|
||||
Query::AUTH_DELETE_SESSIONS_BY_USER => r#"DELETE FROM user_sessions WHERE user_id = ?"#,
|
||||
Query::AUTH_USER_BY_SESSION => {
|
||||
r#"SELECT u.id, u.nickname, u.email, CAST(u.password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, u.confirmed_at, CAST(CASE WHEN u.is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ? AND u.is_active = 1"#
|
||||
r#"SELECT u.id, u.nickname, u.email, CAST(u.password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, u.confirmed_at, CAST(CASE WHEN u.is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(u.theme AS CHAR CHARACTER SET utf8mb4) AS theme FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ? AND u.is_active = 1"#
|
||||
}
|
||||
Query::AUTH_INSERT_SESSION => {
|
||||
r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)"#
|
||||
}
|
||||
Query::AUTH_USER_BY_NICKNAME => {
|
||||
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM users WHERE nickname_key = ?"#
|
||||
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(theme AS CHAR CHARACTER SET utf8mb4) AS theme FROM users WHERE nickname_key = ?"#
|
||||
}
|
||||
Query::AUTH_USER_BY_EMAIL => {
|
||||
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM users WHERE email_key = ?"#
|
||||
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(theme AS CHAR CHARACTER SET utf8mb4) AS theme FROM users WHERE email_key = ?"#
|
||||
}
|
||||
Query::AUTH_USER_BY_SHARE_IDENTIFIER => {
|
||||
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM users JOIN (SELECT ? AS identifier) lookup ON 1 = 1 WHERE is_active = 1 AND (email_key = lookup.identifier OR LOWER(directory_username) = lookup.identifier OR LOWER(external_id) = lookup.identifier) LIMIT 1"#
|
||||
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(theme AS CHAR CHARACTER SET utf8mb4) AS theme FROM users JOIN (SELECT ? AS identifier) lookup ON 1 = 1 WHERE is_active = 1 AND (email_key = lookup.identifier OR LOWER(directory_username) = lookup.identifier OR LOWER(external_id) = lookup.identifier) LIMIT 1"#
|
||||
}
|
||||
Query::USER_ATTACH_WORKSPACE => {
|
||||
r#"INSERT INTO user_workspaces (user_id, workspace_id) SELECT ?, id FROM workspaces WHERE slug = ?"#
|
||||
|
||||
@@ -31,6 +31,9 @@ pub fn get(query: Query) -> &'static str {
|
||||
Query::AUTH_UPDATE_EDITOR_COLOR => {
|
||||
r#"UPDATE users SET editor_color = $1, updated_at = $2 WHERE id = $3"#
|
||||
}
|
||||
Query::AUTH_UPDATE_THEME => {
|
||||
r#"UPDATE users SET theme = $1, updated_at = $2 WHERE id = $3"#
|
||||
}
|
||||
Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = $1"#,
|
||||
Query::RESOURCE_COLOR_BY_USER => {
|
||||
r#"SELECT color FROM user_resource_colors WHERE user_id = $1 AND resource_kind = $2 AND resource_slug = $3"#
|
||||
@@ -103,7 +106,7 @@ pub fn get(query: Query) -> &'static str {
|
||||
r#"SELECT auth_provider, directory_display_name FROM users WHERE id = $1"#
|
||||
}
|
||||
Query::AUTH_USER_BY_EXTERNAL_ID => {
|
||||
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active FROM users WHERE auth_provider = $1 AND external_id = $2"#
|
||||
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, theme FROM users WHERE auth_provider = $1 AND external_id = $2"#
|
||||
}
|
||||
Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = $1"#,
|
||||
Query::AUTH_ANONYMIZE_USER => {
|
||||
@@ -152,19 +155,19 @@ pub fn get(query: Query) -> &'static str {
|
||||
}
|
||||
Query::AUTH_DELETE_SESSIONS_BY_USER => r#"DELETE FROM user_sessions WHERE user_id = $1"#,
|
||||
Query::AUTH_USER_BY_SESSION => {
|
||||
r#"SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at, CAST(CASE WHEN u.is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = $1 AND s.expires_at > $2 AND u.is_active = TRUE"#
|
||||
r#"SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at, CAST(CASE WHEN u.is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, u.theme FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = $1 AND s.expires_at > $2 AND u.is_active = TRUE"#
|
||||
}
|
||||
Query::AUTH_INSERT_SESSION => {
|
||||
r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES ($1, $2, $3)"#
|
||||
}
|
||||
Query::AUTH_USER_BY_NICKNAME => {
|
||||
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active FROM users WHERE nickname_key = $1"#
|
||||
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, theme FROM users WHERE nickname_key = $1"#
|
||||
}
|
||||
Query::AUTH_USER_BY_EMAIL => {
|
||||
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active FROM users WHERE email_key = $1"#
|
||||
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, theme FROM users WHERE email_key = $1"#
|
||||
}
|
||||
Query::AUTH_USER_BY_SHARE_IDENTIFIER => {
|
||||
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active FROM users WHERE is_active = TRUE AND (email_key = $1 OR LOWER(directory_username) = $1 OR LOWER(external_id) = $1) LIMIT 1"#
|
||||
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, theme FROM users WHERE is_active = TRUE AND (email_key = $1 OR LOWER(directory_username) = $1 OR LOWER(external_id) = $1) LIMIT 1"#
|
||||
}
|
||||
Query::USER_ATTACH_WORKSPACE => {
|
||||
r#"INSERT INTO user_workspaces (user_id, workspace_id) SELECT $1, id FROM workspaces WHERE slug = $2"#
|
||||
|
||||
@@ -31,6 +31,9 @@ pub fn get(query: Query) -> &'static str {
|
||||
Query::AUTH_UPDATE_EDITOR_COLOR => {
|
||||
r#"UPDATE users SET editor_color = ?, updated_at = ? WHERE id = ?"#
|
||||
}
|
||||
Query::AUTH_UPDATE_THEME => {
|
||||
r#"UPDATE users SET theme = ?, updated_at = ? WHERE id = ?"#
|
||||
}
|
||||
Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = ?"#,
|
||||
Query::RESOURCE_COLOR_BY_USER => {
|
||||
r#"SELECT color FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"#
|
||||
@@ -103,7 +106,7 @@ pub fn get(query: Query) -> &'static str {
|
||||
r#"SELECT auth_provider, directory_display_name FROM users WHERE id = ?"#
|
||||
}
|
||||
Query::AUTH_USER_BY_EXTERNAL_ID => {
|
||||
r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active FROM users WHERE auth_provider = ? AND external_id = ?"#
|
||||
r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active, theme FROM users WHERE auth_provider = ? AND external_id = ?"#
|
||||
}
|
||||
Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = ?"#,
|
||||
Query::AUTH_ANONYMIZE_USER => {
|
||||
@@ -150,19 +153,19 @@ pub fn get(query: Query) -> &'static str {
|
||||
}
|
||||
Query::AUTH_DELETE_SESSIONS_BY_USER => r#"DELETE FROM user_sessions WHERE user_id = ?"#,
|
||||
Query::AUTH_USER_BY_SESSION => {
|
||||
r#"SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at, u.is_active FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ? AND u.is_active = 1"#
|
||||
r#"SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at, u.is_active, u.theme FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ? AND u.is_active = 1"#
|
||||
}
|
||||
Query::AUTH_INSERT_SESSION => {
|
||||
r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)"#
|
||||
}
|
||||
Query::AUTH_USER_BY_NICKNAME => {
|
||||
r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active FROM users WHERE nickname_key = ?"#
|
||||
r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active, theme FROM users WHERE nickname_key = ?"#
|
||||
}
|
||||
Query::AUTH_USER_BY_EMAIL => {
|
||||
r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active FROM users WHERE email_key = ?"#
|
||||
r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active, theme FROM users WHERE email_key = ?"#
|
||||
}
|
||||
Query::AUTH_USER_BY_SHARE_IDENTIFIER => {
|
||||
r#"SELECT id, nickname, email, password_hash, confirmed_at, CASE WHEN is_active THEN 1 ELSE 0 END AS is_active FROM users WHERE is_active = 1 AND (email_key = ?1 OR LOWER(directory_username) = ?1 OR LOWER(external_id) = ?1) LIMIT 1"#
|
||||
r#"SELECT id, nickname, email, password_hash, confirmed_at, CASE WHEN is_active THEN 1 ELSE 0 END AS is_active, theme FROM users WHERE is_active = 1 AND (email_key = ?1 OR LOWER(directory_username) = ?1 OR LOWER(external_id) = ?1) LIMIT 1"#
|
||||
}
|
||||
Query::USER_ATTACH_WORKSPACE => {
|
||||
r#"INSERT INTO user_workspaces (user_id, workspace_id) SELECT ?, id FROM workspaces WHERE slug = ?"#
|
||||
|
||||
+721
-180
File diff suppressed because it is too large
Load Diff
+4
-3
@@ -4,9 +4,10 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>__DOCUMENT_TITLE__ · __PARENT_TITLE__</title>
|
||||
__APP_STYLESHEET__
|
||||
__APP_THEME_BOOTSTRAP__
|
||||
__APP_STYLESHEET__
|
||||
__APP_IMPORT_MAP__
|
||||
__APP_ENTRYPOINT__
|
||||
</head>
|
||||
@@ -136,7 +137,7 @@
|
||||
disabled>Full</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="participant-badges" class="participant-badges" aria-label="Participants"></div>
|
||||
<div id="participant-badges" class="participant-badges" aria-label="Participants" hidden></div>
|
||||
<div class="editor-shell">
|
||||
<div id="line-gutter" class="line-gutter" aria-hidden="true"></div>
|
||||
<div id="authorship-layer" class="authorship-layer" aria-hidden="true"></div>
|
||||
|
||||
+2
-1
@@ -4,9 +4,10 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>__ERROR_TITLE__ · RustPad</title>
|
||||
__APP_THEME_BOOTSTRAP__
|
||||
__APP_STYLESHEET__
|
||||
</head>
|
||||
|
||||
|
||||
+19
-3
@@ -4,8 +4,9 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>RustPad</title>
|
||||
__APP_THEME_BOOTSTRAP__
|
||||
__APP_STYLESHEET__
|
||||
__APP_IMPORT_MAP__
|
||||
__APP_ENTRYPOINT__
|
||||
@@ -144,6 +145,19 @@
|
||||
<label>Nickname<input id="profile-nickname" maxlength="40" required></label>
|
||||
<label class="profile-color-field">Editor color<input id="profile-color" type="color"
|
||||
aria-label="Choose your editor color"></label>
|
||||
<fieldset class="profile-theme-field">
|
||||
<legend>Interface theme</legend>
|
||||
<div class="theme-options">
|
||||
<label class="theme-option">
|
||||
<input type="radio" name="profile-theme" value="dark" checked>
|
||||
<span><strong>Dark</strong><small>Current RustPad appearance</small></span>
|
||||
</label>
|
||||
<label class="theme-option">
|
||||
<input type="radio" name="profile-theme" value="light">
|
||||
<span><strong>Light</strong><small>Warm cream surfaces and dark text</small></span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<label data-local-profile-field>Current e-mail<input id="profile-current-email" type="email" readonly></label>
|
||||
<label data-local-profile-field>New e-mail<input id="profile-email" type="email" maxlength="320"
|
||||
placeholder="Leave empty to keep current"></label>
|
||||
@@ -152,8 +166,10 @@
|
||||
<label data-local-profile-field>Current password<input id="profile-password" type="password" minlength="8"
|
||||
maxlength="128" placeholder="Required for e-mail or password changes"></label>
|
||||
</div>
|
||||
<button class="primary-button" type="submit">Save profile</button>
|
||||
<button id="profile-delete" data-local-profile-field class="danger-button" type="button">Delete account</button>
|
||||
<div class="profile-actions">
|
||||
<button class="primary-button" type="submit">Save profile</button>
|
||||
<button id="profile-delete" data-local-profile-field class="danger-button" type="button">Delete account</button>
|
||||
</div>
|
||||
<p id="profile-message" class="form-message" role="status"></p>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
+5
-2
@@ -12,6 +12,7 @@ installGlobalDiagnostics();
|
||||
|
||||
import { bindIdentityDialog, handleAccountActionToken, handleAccountConfirmationToken, handleResetToken, handleShareInvitationToken, logoutCurrentSession, validateCurrentSession } from "@rustpad/auth-ui";
|
||||
import { getAuthToken, getGuestId, setAccessToken } from "@rustpad/session";
|
||||
import { applyTheme, getTheme } from "@rustpad/theme";
|
||||
import { api } from "@rustpad/api";
|
||||
import { copyText } from "@rustpad/clipboard";
|
||||
import { safeAppUrl } from "@rustpad/security";
|
||||
@@ -313,6 +314,8 @@ if (identityDialog) {
|
||||
document.querySelector("#profile-nickname").value = currentSession?.nickname || "";
|
||||
const profileColor = document.querySelector("#profile-color");
|
||||
profileColor.value = currentSession?.editor_color || "#7c6cff";
|
||||
const selectedTheme = currentSession?.theme || getTheme();
|
||||
profileForm.querySelectorAll(`input[name="profile-theme"]`).forEach(input => { input.checked = input.value === selectedTheme; });
|
||||
document.querySelector("#profile-current-email").value = currentSession?.email || "";
|
||||
document.querySelector("#profile-email").value = "";
|
||||
document.querySelector("#profile-new-password").value = "";
|
||||
@@ -322,7 +325,7 @@ if (identityDialog) {
|
||||
profileMessage.classList.remove("success", "error");
|
||||
const directoryManaged = Boolean(currentSession?.directory_managed);
|
||||
document.querySelector("#profile-copy").textContent = directoryManaged
|
||||
? "Directory account details are read-only. You can change only the displayed nickname."
|
||||
? "Directory account details are read-only. You can change the nickname, editor color, and interface theme."
|
||||
: "Manage your local RustPad account.";
|
||||
document.querySelectorAll("[data-local-profile-field]").forEach(element => { element.hidden = directoryManaged; });
|
||||
document.querySelectorAll("[data-directory-profile-field]").forEach(element => { element.hidden = !directoryManaged; });
|
||||
@@ -338,7 +341,7 @@ if (identityDialog) {
|
||||
profileDialog?.addEventListener("click", event => { if (event.target === profileDialog) profileDialog.close(); });
|
||||
profileForm?.addEventListener("submit", async event => {
|
||||
event.preventDefault(); const message = document.querySelector("#profile-message"); message.textContent = ""; message.classList.remove("success", "error");
|
||||
try { const selectedColor = document.querySelector("#profile-color").value; const newEmail = currentSession?.directory_managed ? null : (document.querySelector("#profile-email").value.trim() || null); const newPassword = currentSession?.directory_managed ? null : (document.querySelector("#profile-new-password").value || null); const password = currentSession?.directory_managed ? "" : document.querySelector("#profile-password").value; if ((newEmail || newPassword) && !password) throw new Error("Enter the current password to change e-mail or password."); const result = await api("/api/auth/profile", { method: "POST", headers: authHeaders(), body: JSON.stringify({ nickname: document.querySelector("#profile-nickname").value.trim(), editor_color: selectedColor, new_email: newEmail, new_password: newPassword, password }) }); message.textContent = result.message; message.classList.add("success"); currentSession.nickname = result.nickname; currentSession.editor_color = result.editor_color; renderAccount(currentSession); } catch (e) { message.textContent = e.message; message.classList.add("error"); }
|
||||
try { const selectedColor = document.querySelector("#profile-color").value; const selectedTheme = profileForm.querySelector(`input[name="profile-theme"]:checked`)?.value || "dark"; const newEmail = currentSession?.directory_managed ? null : (document.querySelector("#profile-email").value.trim() || null); const newPassword = currentSession?.directory_managed ? null : (document.querySelector("#profile-new-password").value || null); const password = currentSession?.directory_managed ? "" : document.querySelector("#profile-password").value; if ((newEmail || newPassword) && !password) throw new Error("Enter the current password to change e-mail or password."); const result = await api("/api/auth/profile", { method: "POST", headers: authHeaders(), body: JSON.stringify({ nickname: document.querySelector("#profile-nickname").value.trim(), editor_color: selectedColor, theme: selectedTheme, new_email: newEmail, new_password: newPassword, password }) }); message.textContent = result.message; message.classList.add("success"); currentSession.nickname = result.nickname; currentSession.editor_color = result.editor_color; currentSession.theme = result.theme; applyTheme(result.theme); renderAccount(currentSession); } catch (e) { message.textContent = e.message; message.classList.add("error"); }
|
||||
});
|
||||
document.querySelector("#profile-delete")?.addEventListener("click", async () => {
|
||||
const message = document.querySelector("#profile-message"); const password = document.querySelector("#profile-password").value;
|
||||
|
||||
@@ -21,6 +21,7 @@ import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui";
|
||||
import { bindNoteFiles } from "@rustpad/note-files";
|
||||
import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
|
||||
import { toast } from "@rustpad/toast";
|
||||
import { getTheme } from "@rustpad/theme";
|
||||
|
||||
export function startNoteEditor(adapter) {
|
||||
const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer");
|
||||
@@ -182,7 +183,7 @@ export function startNoteEditor(adapter) {
|
||||
setStatus(null, "Connecting…");
|
||||
}
|
||||
function updateAddressLabel() { document.querySelector(adapter.addressSelector).textContent = `${location.pathname}${location.search}`; }
|
||||
async function renderMermaid() { const nodes = preview.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '<p class="error">Failed to load Mermaid.</p>')); } }
|
||||
async function renderMermaid() { const nodes = preview.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: getTheme() === "dark" ? "dark" : "default", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '<p class="error">Failed to load Mermaid.</p>')); } }
|
||||
async function renderCodeHighlight() { const nodes = preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)'); if (!nodes.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); nodes.forEach(node => { const lines = node.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(node); return; } const language = [...node.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); node.classList.add("hljs"); }); } catch { } }
|
||||
function renderParticipantBadges(owners) {
|
||||
if (!participantBadges) return;
|
||||
|
||||
+2
-1
@@ -14,6 +14,7 @@ import { api } from "@rustpad/api";
|
||||
import { copyText } from "@rustpad/clipboard";
|
||||
import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles } from "@rustpad/markdown";
|
||||
import { toast } from "@rustpad/toast";
|
||||
import { getTheme } from "@rustpad/theme";
|
||||
|
||||
const token = location.pathname.split("/").filter(Boolean)[1];
|
||||
const content = document.querySelector("#public-content");
|
||||
@@ -21,7 +22,7 @@ const lineNumbersToggle = document.querySelector("#public-line-numbers-toggle");
|
||||
const passwordDialog = document.querySelector("#public-password-dialog"), passwordForm = document.querySelector("#public-password-form"), passwordInput = document.querySelector("#public-password"), passwordError = document.querySelector("#public-password-error");
|
||||
let pagePassword = "";
|
||||
function pageHeaders() { return pagePassword ? { "X-RustPad-Page-Password": pagePassword } : {}; }
|
||||
async function renderMermaid() { const nodes = content.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '<p class="error">Failed to load Mermaid.</p>')); } }
|
||||
async function renderMermaid() { const nodes = content.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: getTheme() === "dark" ? "dark" : "default", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '<p class="error">Failed to load Mermaid.</p>')); } }
|
||||
async function renderCodeHighlight() { const blocks = content.querySelectorAll('pre code[class^="language-"]'); if (!blocks.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); blocks.forEach(block => { const lines = block.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(block); return; } const language = [...block.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); block.classList.add("hljs"); }); } catch { } }
|
||||
function lockPublicContent(allowTaskUpdates) {
|
||||
content.querySelectorAll('[contenteditable]').forEach(node => node.removeAttribute('contenteditable'));
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
import { applySessionTheme } from "@rustpad/theme";
|
||||
|
||||
const ACCESS_STORAGE_VERSION_KEY = "rustpad:access-storage-version";
|
||||
const ACCESS_STORAGE_VERSION = "http-only-cookie-v1";
|
||||
|
||||
@@ -77,6 +79,7 @@ export function getAuthToken() { return localStorage.getItem(AUTH_STATE_KEY) ? "
|
||||
export function setAuthSession(session) {
|
||||
localStorage.setItem(AUTH_STATE_KEY, "1");
|
||||
setNickname(session.nickname);
|
||||
applySessionTheme(session);
|
||||
}
|
||||
export function clearAuthSession() {
|
||||
localStorage.removeItem(AUTH_STATE_KEY);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
const STORAGE_KEY = "rustpad:theme";
|
||||
const DEFAULT_THEME = "dark";
|
||||
const THEMES = new Set(["dark", "light"]);
|
||||
|
||||
function normalizeTheme(value) {
|
||||
return THEMES.has(value) ? value : DEFAULT_THEME;
|
||||
}
|
||||
|
||||
function updateBrowserChrome(theme) {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
document.querySelector('meta[name="color-scheme"]')?.setAttribute("content", theme);
|
||||
}
|
||||
|
||||
export function getTheme() {
|
||||
return normalizeTheme(document.documentElement.dataset.theme);
|
||||
}
|
||||
|
||||
export function applyTheme(value, { persist = true } = {}) {
|
||||
const theme = normalizeTheme(value);
|
||||
const previousTheme = getTheme();
|
||||
updateBrowserChrome(theme);
|
||||
if (persist) {
|
||||
try { localStorage.setItem(STORAGE_KEY, theme); } catch { }
|
||||
}
|
||||
if (theme !== previousTheme) {
|
||||
window.dispatchEvent(new CustomEvent("rustpad:theme-change", { detail: { theme } }));
|
||||
}
|
||||
return theme;
|
||||
}
|
||||
|
||||
export function applySessionTheme(session) {
|
||||
if (session?.theme) applyTheme(session.theme);
|
||||
}
|
||||
|
||||
window.addEventListener("storage", event => {
|
||||
if (event.key === STORAGE_KEY && event.newValue) applyTheme(event.newValue, { persist: false });
|
||||
});
|
||||
+2
-1
@@ -4,8 +4,9 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>Published note · RustPad</title>
|
||||
__APP_THEME_BOOTSTRAP__
|
||||
__APP_STYLESHEET__
|
||||
__APP_IMPORT_MAP__
|
||||
__APP_ENTRYPOINT__
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>__WORKSPACE_TITLE__ · RustPad</title>
|
||||
__APP_STYLESHEET__
|
||||
__APP_THEME_BOOTSTRAP__
|
||||
__APP_STYLESHEET__
|
||||
__APP_IMPORT_MAP__
|
||||
__APP_ENTRYPOINT__
|
||||
</head>
|
||||
|
||||
Reference in New Issue
Block a user