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.
159 lines
6.6 KiB
Rust
159 lines
6.6 KiB
Rust
//! 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>");
|
|
}
|
|
}
|