Files
keeporg/internal/server/pages.go
T
root 8bbb3179a1 Add KeepOrg sync server: accounts, nested groups, versioned KDBX sync
Why: collaborative KeePass use needs one arbiter of "who wrote last" that
never sees a master password, plus access control that follows an org chart.

What: stdlib-only Go server. Metadata is a single JSON document with one
writer and atomic commit; KDBX payloads are versioned blobs on disk. Access
comes from a group tree where a grant on a group reaches every member below
it. Login is password+TOTP, invite links, or OIDC through a browser-approved
device flow. Pushes carry If-Match and are arbitrated by a per-database merge
strategy (server-wins by default). Updates are pushed to clients over SSE.

Proved: go test ./internal/... covers RFC 6238 vectors, ACL inheritance and
isolation, store rollback, blob pruning, every push verdict, and an
end-to-end HTTP pass over login, conflict, force, read-only grants, invite
redemption, TOTP enrolment, device login and the event stream.
2026-10-29 15:54:10 +01:00

85 lines
4.5 KiB
Go

package server
import (
"html/template"
"net/http"
)
// The server has no browsable UI. These three pages exist only because a
// browser is the right place to approve a device, redeem an invite and run an
// OIDC round trip.
var pages = template.Must(template.New("layout").Parse(`
{{define "layout"}}<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{.Title}} &middot; KeepOrg</title>
<style>
:root{color-scheme:light dark;--fg:#1a1a1a;--bg:#fafafa;--card:#fff;--line:#d8d8d8;--accent:#2d6a4f}
@media(prefers-color-scheme:dark){:root{--fg:#e8e8e8;--bg:#161616;--card:#1f1f1f;--line:#3a3a3a;--accent:#74c69d}}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--bg);color:var(--fg);
font:16px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;padding:24px}
main{width:100%;max-width:26rem;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:28px}
h1{font-size:1.25rem;margin:0 0 .25rem}
p.sub{margin:0 0 1.25rem;opacity:.7;font-size:.9rem}
label{display:block;font-size:.8rem;text-transform:uppercase;letter-spacing:.04em;opacity:.7;margin:.9rem 0 .25rem}
input{width:100%;padding:.6rem .7rem;font:inherit;color:var(--fg);background:transparent;
border:1px solid var(--line);border-radius:8px}
button{width:100%;margin-top:1.4rem;padding:.7rem;font:inherit;font-weight:600;color:#fff;
background:var(--accent);border:0;border-radius:8px;cursor:pointer}
.err{margin:0 0 1rem;padding:.6rem .7rem;border-radius:8px;background:#f8d7da;color:#842029;font-size:.9rem}
.alt{display:block;margin-top:1rem;text-align:center;font-size:.9rem;color:var(--accent)}
code{font-size:1.1rem;letter-spacing:.08em}
</style></head><body><main>{{template "body" .}}</main></body></html>{{end}}
`))
var deviceTmpl = template.Must(template.Must(pages.Clone()).Parse(`
{{define "body"}}<h1>Approve this device</h1>
<p class="sub">Sign in to let the client on your device sync your databases.</p>
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
<form method="post" action="/device">
<label for="code">Device code</label><input id="code" name="code" value="{{.Code}}" required autocapitalize="characters">
<label for="username">Username</label><input id="username" name="username" autocomplete="username" required>
<label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password" required>
<label for="totp">One-time code <small>(if enabled)</small></label><input id="totp" name="totp" inputmode="numeric" autocomplete="one-time-code">
<button type="submit">Approve</button>
</form>
{{if .OIDC}}<a class="alt" href="/oidc/login?code={{.Code}}">Sign in with single sign-on &rarr;</a>{{end}}
{{end}}`))
var inviteTmpl = template.Must(template.Must(pages.Clone()).Parse(`
{{define "body"}}<h1>Create your account</h1>
<p class="sub">You were invited to a KeepOrg server.</p>
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
<form method="post" action="/invite/{{.Token}}">
<label for="username">Username</label><input id="username" name="username" autocomplete="username" required>
<label for="email">Email <small>(optional)</small></label><input id="email" name="email" type="email" autocomplete="email">
<label for="password">Password</label><input id="password" name="password" type="password" autocomplete="new-password" minlength="10" required>
<label for="password2">Repeat password</label><input id="password2" name="password2" type="password" autocomplete="new-password" minlength="10" required>
<button type="submit">Create account</button>
</form>
{{end}}`))
var doneTmpl = template.Must(template.Must(pages.Clone()).Parse(`
{{define "body"}}<h1>{{.Title}}</h1><p class="sub">{{.Message}}</p>{{end}}`))
func (s *Server) renderDevice(w http.ResponseWriter, code, errMsg string) {
render(w, deviceTmpl, map[string]any{
"Title": "Approve device", "Code": code, "Error": errMsg, "OIDC": s.oidc != nil,
})
}
func (s *Server) renderInvite(w http.ResponseWriter, token, errMsg string) {
render(w, inviteTmpl, map[string]any{"Title": "Create account", "Token": token, "Error": errMsg})
}
func (s *Server) renderDone(w http.ResponseWriter, title, msg string) {
render(w, doneTmpl, map[string]any{"Title": title, "Message": msg})
}
func render(w http.ResponseWriter, t *template.Template, data map[string]any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Referrer-Policy", "no-referrer")
_ = t.ExecuteTemplate(w, "layout", data)
}