mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-25 11:44:16 +00:00
did:plc Identity Support (pkg/hold/pds/did.go, pkg/hold/config.go, pkg/hold/server.go) The big feature — holds can now use did:plc identities instead of only did:web. This adds: - LoadOrCreateDID() — resolves hold DID by priority: config DID > did.txt on disk > create new - CreatePLCIdentity() — builds a genesis operation, signs with rotation key, submits to PLC directory - EnsurePLCCurrent() — on boot, compares local signing key + URL against PLC directory and auto-updates if they've drifted (requires rotation key) - New config fields: did_method (web/plc), did, plc_directory_url, rotation_key_path - GenerateDIDDocument() now uses the stored DID instead of always deriving did:web from URL - NewHoldServer wired up to call LoadOrCreateDID instead of GenerateDIDFromURL CAR Export/Import (pkg/hold/pds/export.go, pkg/hold/pds/import.go, cmd/hold/repo.go) New CLI subcommands for repo backup/restore: - atcr-hold repo export — streams the hold's repo as a CAR file to stdout - atcr-hold repo import <file>... — reads CAR files, upserts all records in a single atomic commit. Uses a bulkImportRecords method that opens a delta session, checks each record for create vs update, commits once, and fires repo events. - openHoldPDS() helper to spin up a HoldPDS from config for offline CLI operations Admin UI Fixes (pkg/hold/admin/) - Logout changed from GET to POST — nav template now uses a <form method=POST> instead of an <a> link (prevents CSRF on logout) - Removed return_to parameter from login flow — simplified redirect logic, auth middleware now redirects to /admin/auth/login without query params Config/Deploy - config-hold.example.yaml and deploy/upcloud/configs/hold.yaml.tmpl updated with the four new did:plc config fields - go.mod / go.sum — added github.com/did-method-plc/go-didplc dependency
134 lines
3.8 KiB
Go
134 lines
3.8 KiB
Go
package admin
|
|
|
|
import (
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"atcr.io/pkg/atproto"
|
|
)
|
|
|
|
// handleLogin renders the login page
|
|
func (ui *AdminUI) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
// If already logged in, redirect to dashboard
|
|
if token, ok := getSessionCookie(r); ok {
|
|
if session := ui.getSession(token); session != nil {
|
|
// Verify still owner
|
|
if _, captain, err := ui.pds.GetCaptainRecord(r.Context()); err == nil && session.DID == captain.Owner {
|
|
http.Redirect(w, r, "/admin", http.StatusFound)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
data := struct {
|
|
PageData
|
|
Error string
|
|
}{
|
|
PageData: PageData{
|
|
Title: "Login",
|
|
ActivePage: "login",
|
|
HoldDID: ui.pds.DID(),
|
|
},
|
|
Error: r.URL.Query().Get("error"),
|
|
}
|
|
|
|
ui.renderTemplate(w, "pages/login.html", data)
|
|
}
|
|
|
|
// handleAuthorize starts the OAuth flow
|
|
func (ui *AdminUI) handleAuthorize(w http.ResponseWriter, r *http.Request) {
|
|
handle := strings.TrimSpace(r.URL.Query().Get("handle"))
|
|
if handle == "" {
|
|
http.Redirect(w, r, "/admin/auth/login?error=Handle+is+required", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
// Normalize handle
|
|
handle = strings.TrimPrefix(handle, "@")
|
|
|
|
// Resolve handle to DID
|
|
did, _, _, err := atproto.ResolveIdentity(r.Context(), handle)
|
|
if err != nil {
|
|
slog.Warn("Failed to resolve handle for admin login", "handle", handle, "error", err)
|
|
http.Redirect(w, r, "/admin/auth/login?error=Could+not+resolve+handle", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
slog.Info("Starting admin OAuth flow", "handle", handle, "did", did)
|
|
|
|
// Start OAuth flow
|
|
authURL, err := ui.clientApp.StartAuthFlow(r.Context(), did)
|
|
if err != nil {
|
|
slog.Error("Failed to start OAuth flow", "error", err)
|
|
http.Redirect(w, r, "/admin/auth/login?error=OAuth+initialization+failed", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, authURL, http.StatusFound)
|
|
}
|
|
|
|
// handleCallback processes the OAuth callback
|
|
func (ui *AdminUI) handleCallback(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
// Process OAuth callback
|
|
sessionData, err := ui.clientApp.ProcessCallback(ctx, r.URL.Query())
|
|
if err != nil {
|
|
slog.Error("OAuth callback failed", "error", err)
|
|
http.Redirect(w, r, "/admin/auth/login?error=OAuth+authentication+failed", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
did := sessionData.AccountDID.String()
|
|
|
|
// Resolve handle from DID
|
|
_, handle, _, err := atproto.ResolveIdentity(ctx, did)
|
|
if err != nil {
|
|
slog.Warn("Failed to resolve handle from DID", "did", did, "error", err)
|
|
handle = did // Fallback to DID
|
|
}
|
|
|
|
slog.Info("OAuth callback successful", "did", did, "handle", handle)
|
|
|
|
// Get captain record to check owner
|
|
_, captain, err := ui.pds.GetCaptainRecord(ctx)
|
|
if err != nil {
|
|
slog.Error("Failed to get captain record during OAuth callback", "error", err)
|
|
http.Redirect(w, r, "/admin/auth/login?error=Failed+to+verify+ownership", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
// CRITICAL: Only allow the hold owner
|
|
if did != captain.Owner {
|
|
slog.Warn("Non-owner attempted admin access",
|
|
"did", did,
|
|
"handle", handle,
|
|
"owner", captain.Owner)
|
|
http.Redirect(w, r, "/admin/auth/login?error=Access+denied:+Only+the+hold+owner+can+access+the+admin+panel", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
// Create session and set cookie
|
|
token, err := ui.createSession(did, handle)
|
|
if err != nil {
|
|
slog.Error("failed to create session token", "error", err, "path", r.URL.Path)
|
|
http.Error(w, "Failed to create session", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
ui.setSessionCookie(w, r, token)
|
|
|
|
slog.Info("Admin login successful", "did", did, "handle", handle)
|
|
|
|
http.Redirect(w, r, "/admin", http.StatusFound)
|
|
}
|
|
|
|
// handleLogout clears the session and redirects to login
|
|
func (ui *AdminUI) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
if token, ok := getSessionCookie(r); ok {
|
|
ui.deleteSession(token)
|
|
}
|
|
clearSessionCookie(w)
|
|
http.Redirect(w, r, "/admin/auth/login", http.StatusFound)
|
|
}
|