Replace folder sync with an HTTP sync server, keeping the app offline-first
Folder sync left too much to the user's sync tool: platform file access
(the Android SAF plugin), choosing the document, merging conflict copies —
and its merge base could fall out of step with the document (absorbing
conflict copies, "forget", picking a new Android folder), reading every
difference as a deletion.
The new mtgc-server crate stores one versioned library per user and accepts
a write only when it names the version it replaces. It never merges: devices
still do that with merge3, through a new sync cycle over a `Remote` trait in
mtgc-core. The base advances only on knowledge — to what was read, or to a
write the server confirmed — and is stored with the server and account it
belongs to, so a different account starts from an empty ancestor instead of
deleting. The working copy is only rewritten under the app-state lock, and
sync commands now run off the main thread.
Sign-in happens in the browser on the server's pages, via the owner's OIDC
provider and/or local username/password accounts (Argon2id, managed with
`mtgc-server user ...`), and returns a per-device revocable token to the app
by PKCE-protected code: a loopback port on desktop, the mtgc:// scheme on
Android.
The Info tab gains sign-in, the first-sync combine/replace choice, the
account's devices, and JSON export/import; the top bar shows sync state.
Includes a Dockerfile, Compose/Caddy example, and scripts to build and push
${DOCKER_REGISTRY}/mtgc/sync-server.
This commit is contained in:
158
crates/mtgc-server/src/pages.rs
Normal file
158
crates/mtgc-server/src/pages.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
//! The few HTML pages the server shows during sign-in.
|
||||
//!
|
||||
//! Hand-written and script-free: three small pages don't justify a template
|
||||
//! engine, and with no JavaScript the content security policy can forbid it
|
||||
//! outright.
|
||||
|
||||
use axum::http::header::{CACHE_CONTROL, CONTENT_SECURITY_POLICY, X_FRAME_OPTIONS};
|
||||
use axum::http::{HeaderValue, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
|
||||
use crate::db::PendingLogin;
|
||||
|
||||
/// The sign-in page: a username/password form, plus a button for the
|
||||
/// identity provider when one is configured. `username` refills the form after
|
||||
/// a failed attempt, so only the password needs retyping.
|
||||
pub fn login(
|
||||
pending: &PendingLogin,
|
||||
oidc_name: Option<&str>,
|
||||
error: Option<&str>,
|
||||
username: &str,
|
||||
) -> Response {
|
||||
let error = error
|
||||
.map(|e| format!(r#"<p class="error" role="alert">{}</p>"#, esc(e)))
|
||||
.unwrap_or_default();
|
||||
let oidc = oidc_name
|
||||
.map(|name| {
|
||||
format!(
|
||||
r#"<a class="button secondary" href="/auth/oidc?pending={id}">Sign in with {name}</a>
|
||||
<div class="or"><span>or</span></div>"#,
|
||||
id = esc(&pending.id),
|
||||
name = esc(name),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let body = format!(
|
||||
r#"<h1>Sign in to mtgc</h1>
|
||||
<p class="muted">Signing in <strong>{device}</strong> to sync your library.</p>
|
||||
{error}
|
||||
{oidc}
|
||||
<form method="post" action="/auth/password">
|
||||
<input type="hidden" name="pending" value="{id}">
|
||||
<label>Username <input name="username" value="{username}" autocomplete="username" autocapitalize="none" required{focus_user}></label>
|
||||
<label>Password <input name="password" type="password" autocomplete="current-password" required{focus_password}></label>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>"#,
|
||||
device = esc(&pending.device_name),
|
||||
id = esc(&pending.id),
|
||||
username = esc(username),
|
||||
focus_user = if username.is_empty() { " autofocus" } else { "" },
|
||||
focus_password = if username.is_empty() { "" } else { " autofocus" },
|
||||
);
|
||||
page(StatusCode::OK, "Sign in", &body, "")
|
||||
}
|
||||
|
||||
/// Sign-in finished: send the browser back to the app.
|
||||
///
|
||||
/// A page rather than a bare redirect, because a phone browser may decline to
|
||||
/// follow a redirect into an app on its own; the button then gives the user a
|
||||
/// tap that it will honour.
|
||||
pub fn return_to_app(url: &str) -> Response {
|
||||
let url = esc(url);
|
||||
let body = format!(
|
||||
r#"<h1>Returning to mtgc…</h1>
|
||||
<p class="muted">If the app doesn't open by itself, use the button below. You can close this tab afterwards.</p>
|
||||
<a class="button" href="{url}">Return to mtgc</a>"#
|
||||
);
|
||||
// Meta refresh rather than a 30x, so the page above is what's left showing
|
||||
// if the hand-off doesn't happen.
|
||||
let refresh = format!(r#"<meta http-equiv="refresh" content="0;url={url}">"#);
|
||||
page(StatusCode::OK, "Signed in", &body, &refresh)
|
||||
}
|
||||
|
||||
pub fn error(status: StatusCode, message: &str) -> Response {
|
||||
let body = format!(
|
||||
r#"<h1>Sign-in didn't work</h1>
|
||||
<p>{}</p>"#,
|
||||
esc(message)
|
||||
);
|
||||
page(status, "Sign-in problem", &body, "")
|
||||
}
|
||||
|
||||
fn page(status: StatusCode, title: &str, body: &str, head_extra: &str) -> Response {
|
||||
let mut resp = (status, Html(render(title, body, head_extra))).into_response();
|
||||
let h = resp.headers_mut();
|
||||
h.insert(
|
||||
CONTENT_SECURITY_POLICY,
|
||||
HeaderValue::from_static("default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'"),
|
||||
);
|
||||
// The password page must never be framed by another site.
|
||||
h.insert(X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
|
||||
// Each page embeds a single-use id or code.
|
||||
h.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
resp
|
||||
}
|
||||
|
||||
fn render(title: &str, body: &str, head_extra: &str) -> String {
|
||||
format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
{head_extra}
|
||||
<title>{title} · mtgc</title>
|
||||
<style>{STYLE}</style>
|
||||
</head>
|
||||
<body><main>{body}</main></body>
|
||||
</html>"#,
|
||||
title = esc(title),
|
||||
)
|
||||
}
|
||||
|
||||
/// Matches the app's terracotta brand colour, light and dark.
|
||||
const STYLE: &str = r#"
|
||||
:root { --bg:#faf7f5; --card:#fff; --fg:#2b2320; --muted:#6f625c; --accent:#c8553d; --accent-fg:#fff; --line:#e6ddd8; --err:#a12d1c; color-scheme: light dark; }
|
||||
@media (prefers-color-scheme: dark) { :root { --bg:#1c1816; --card:#262120; --fg:#f1e9e5; --muted:#b3a59e; --line:#3d3532; --err:#ff8f7a; } }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center; background:var(--bg); color:var(--fg); font:16px/1.5 system-ui, sans-serif; padding:16px; }
|
||||
main { width:100%; max-width:380px; background:var(--card); border:1px solid var(--line); border-radius:12px; padding:28px 24px; }
|
||||
h1 { font-size:1.35rem; margin:0 0 8px; }
|
||||
.muted { color:var(--muted); margin:0 0 20px; }
|
||||
.error { color:var(--err); margin:0 0 16px; }
|
||||
form { display:flex; flex-direction:column; gap:14px; }
|
||||
label { display:flex; flex-direction:column; gap:6px; font-size:.9rem; color:var(--muted); }
|
||||
input { font:inherit; color:var(--fg); background:var(--bg); padding:10px 12px; border:1px solid var(--line); border-radius:8px; }
|
||||
input:focus { outline:2px solid var(--accent); outline-offset:1px; }
|
||||
button, .button { display:block; width:100%; text-align:center; font:inherit; font-weight:600; padding:11px; border-radius:8px; border:1px solid var(--accent); background:var(--accent); color:var(--accent-fg); cursor:pointer; text-decoration:none; }
|
||||
.button.secondary { background:transparent; color:var(--accent); }
|
||||
.or { display:flex; align-items:center; gap:10px; color:var(--muted); font-size:.85rem; margin:18px 0; }
|
||||
.or::before, .or::after { content:""; flex:1; border-top:1px solid var(--line); }
|
||||
"#;
|
||||
|
||||
/// Escape text for HTML content and double-quoted attributes.
|
||||
fn esc(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn escaping_neutralises_markup_and_attribute_breakouts() {
|
||||
assert_eq!(esc(r#"<b>"x" & 'y'</b>"#), "<b>"x" & 'y'</b>");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user