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
115 lines
3.3 KiB
Go
115 lines
3.3 KiB
Go
package admin
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// requireOwner middleware ensures the request is from the hold owner
|
|
func (ui *AdminUI) requireOwner(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Get session cookie
|
|
token, ok := getSessionCookie(r)
|
|
if !ok {
|
|
http.Redirect(w, r, "/admin/auth/login", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
// Validate session
|
|
session := ui.getSession(token)
|
|
if session == nil {
|
|
clearSessionCookie(w)
|
|
http.Redirect(w, r, "/admin/auth/login", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
// Double-check DID still matches captain.Owner
|
|
_, captain, err := ui.pds.GetCaptainRecord(r.Context())
|
|
if err != nil {
|
|
slog.Error("Failed to get captain record for admin auth", "error", err)
|
|
http.Error(w, "Failed to verify ownership", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if session.DID != captain.Owner {
|
|
slog.Warn("Admin session DID doesn't match captain owner",
|
|
"sessionDID", session.DID,
|
|
"captainOwner", captain.Owner)
|
|
ui.deleteSession(token)
|
|
clearSessionCookie(w)
|
|
http.Error(w, "Access denied: ownership verification failed", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
// Add session to context for handlers
|
|
ctx := context.WithValue(r.Context(), adminContextKey{}, session)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
// getSessionFromContext retrieves the admin session from context
|
|
func getSessionFromContext(ctx context.Context) *AdminSession {
|
|
session, ok := ctx.Value(adminContextKey{}).(*AdminSession)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return session
|
|
}
|
|
|
|
// PageData contains common data for all admin pages
|
|
type PageData struct {
|
|
Title string
|
|
ActivePage string
|
|
User *AdminSession
|
|
HoldDID string
|
|
Flash *Flash
|
|
}
|
|
|
|
// Flash represents a flash message
|
|
type Flash struct {
|
|
Category string // "success", "error", "warning", "info"
|
|
Message string
|
|
}
|
|
|
|
// newPageData creates PageData with common values
|
|
func (ui *AdminUI) newPageData(r *http.Request, title, activePage string) PageData {
|
|
session := getSessionFromContext(r.Context())
|
|
flash := getFlash(r, ui)
|
|
|
|
return PageData{
|
|
Title: title,
|
|
ActivePage: activePage,
|
|
User: session,
|
|
HoldDID: ui.pds.DID(),
|
|
Flash: flash,
|
|
}
|
|
}
|
|
|
|
// renderTemplate renders a template with the given data.
|
|
// Layout pages (pages/* except login and error) are rendered via "admin-layout"
|
|
// which uses {{block}} overrides. Partials and standalone pages execute directly.
|
|
func (ui *AdminUI) renderTemplate(w http.ResponseWriter, name string, data any) {
|
|
tmpl, ok := ui.templates[name]
|
|
if !ok {
|
|
slog.Error("Template not found", "template", name)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
// Pages that use the shared layout are rendered via the layout entry point.
|
|
// login.html and error.html are standalone (different layout).
|
|
execName := name
|
|
if strings.HasPrefix(name, "pages/") && name != "pages/login.html" && name != "pages/error.html" {
|
|
execName = "admin-layout"
|
|
}
|
|
|
|
if err := tmpl.ExecuteTemplate(w, execName, data); err != nil {
|
|
slog.Error("Failed to render template", "template", name, "exec", execName, "error", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
}
|
|
}
|