package handlers // import "git.griefed.de/griefed/repoman/internal/server/handlers" Package handlers contains repoman's HTTP handlers. They are thin glue between the auth/migrate/inventory packages and the templated views. Templates: this skeleton uses html/template directly so the project builds today. Final implementation switches to `templ` (which compiles templates to Go code at build time) for type-safe templates with less runtime overhead. The handler signatures stay the same; only the internal rendering changes. FUNCTIONS func MustChangePasswordMiddleware(next http.Handler) http.Handler MustChangePasswordMiddleware forces a user whose MustChangePassword flag is set to the password-change page before they can reach any other authenticated route. The change-password page itself, logout, and the 2FA step are allowed through so the user can comply or escape. It must be wired AFTER the Auth middleware so UserFromContext is populated; anonymous requests (user == nil) pass straight through. func NotificationCountMiddleware(count func(context.Context) int) func(http.Handler) http.Handler NotificationCountMiddleware injects a lazy unread-count provider, resolved only when an authenticated page renders (NewBaseData). Static/SSE requests pay nothing. The provider reads the user from context itself. func RenderPage(w http.ResponseWriter, r *http.Request, name string, data any) RenderPage renders a full page by name (e.g. "configs/form.gohtml"). The named template is looked up in pageTemplates — the set parsed once at startup from the embedded files (see embed.go), combining the page's "title"/"content" define blocks with the shared partials and the base layout — and executed as "page". An unknown name is a programmer error (a typo'd call site or a missing file) and yields a 500. func RestorePendingMiddleware(dataDir string) func(http.Handler) http.Handler RestorePendingMiddleware injects a lazy checker reporting whether a database restore is staged under dataDir (the restore-pending dir). It is lazy — the filesystem is stat'd only when a page actually reads it (NewBaseData, for an admin) — so static-asset and SSE requests pay nothing. The check is cheap (one os.Stat) and always reflects the current on-disk state, which clears itself on the next boot when ApplyPendingRestore consumes the dir. func StaticFS() fs.FS StaticFS returns the embedded static-asset filesystem. The router serves it under /static/ via http.FileServerFS. func TokenExpiryNoticesMiddleware(provider func(context.Context) []TokenExpiryNotice) func(http.Handler) http.Handler TokenExpiryNoticesMiddleware injects a lazy provider of expiring-token notices (unfiltered by viewer). It is lazy like RestorePendingMiddleware — the provider queries the DB only when an authenticated page actually reads it (NewBaseData), so static-asset and SSE requests pay nothing. Per-viewer filtering happens in NewBaseData, which keeps only the notices the viewer is permitted to edit. TYPES type AuthHandler struct { Authenticator *auth.Authenticator Sessions *auth.SessionManager Users auth.UserRepository ResetService *auth.PasswordResetService // EmailSender may be a no-op (IsConfigured()=false) when SMTP is // disabled. Handlers must check IsConfigured before assuming a // reset link can be delivered. EmailSender EmailSender // BaseURL is the externally-visible URL of the repoman instance, // used to construct absolute reset links in emails. Without a // trailing slash. BaseURL string // SecureCookies controls the Secure cookie flag. False is only // permitted for localhost dev. SecureCookies bool // PostLoginPath is where successful logins are sent when no `next` // query parameter is provided. Default "/". PostLoginPath string // Settings provides current auth settings (token validity, etc.) // at request time. Function indirection lets reconfiguration take // effect without restarts. Settings func() auth.Settings // Audit records privileged actions; nil disables audit logging. Audit *audit.Logger } AuthHandler bundles the dependencies for /auth/* endpoints. The fields are exported so the cmd/repoman wiring can build the handler with concrete dependencies; all the methods on it are http.HandlerFunc-compatible. func (h *AuthHandler) ForgotPasswordGET(w http.ResponseWriter, r *http.Request) ForgotPasswordGET renders the email-entry form. Available only when SMTP is configured; the route is omitted from the router otherwise so this handler should not normally be reached in disabled mode. func (h *AuthHandler) ForgotPasswordPOST(w http.ResponseWriter, r *http.Request) ForgotPasswordPOST issues a reset token (silently — no confirmation that the email was found), sends the email, and renders a generic acknowledgement. The same acknowledgement is shown for unknown emails to prevent enumeration. func (h *AuthHandler) KeepAlivePOST(w http.ResponseWriter, r *http.Request) KeepAlivePOST records in-page user activity on the current session, resetting the server-side idle clock (POST /auth/keepalive). The client posts it on throttled genuine interaction (mousemove/keydown/scroll/…) so a user actively working on a single page — e.g. watching a run monitor — is not idled out, while a backgrounded/idle tab sends nothing and still times out. It sits behind the auth middleware, so an already-expired session gets the normal 303-to-login instead of reaching here. Responds 204 with no body. func (h *AuthHandler) LoginGET(w http.ResponseWriter, r *http.Request) LoginGET renders the login form for unauthenticated GET requests. func (h *AuthHandler) LoginPOST(w http.ResponseWriter, r *http.Request) LoginPOST processes a submitted login form. Flow: 1. Parse form (username, password, next-URL) 2. Authenticator.Authenticate (handles all the lockout magic) 3. On success: set session cookie, redirect to /auth/2fa or post-login 4. On failure: re-render login with generic "Invalid credentials" func (h *AuthHandler) LogoutPOST(w http.ResponseWriter, r *http.Request) LogoutPOST destroys the current session and redirects to the login page with a "Logged out" flash. func (h *AuthHandler) ResetGET(w http.ResponseWriter, r *http.Request) ResetGET renders the new-password form for a reset link. Both the token and the token-id are read from query parameters per the project decision to use the query-variant URL. func (h *AuthHandler) ResetPOST(w http.ResponseWriter, r *http.Request) ResetPOST consumes a reset token and sets the new password. Failure paths surface different messages: - Bad token: "Invalid or expired reset link." - Weak password: the actual policy violation - Internal error: 500 with no detail type BaseData struct { // Nav/layout Username string // DisplayName is the user's chosen display name shown in the nav; empty // falls back to Username (which stays the immutable login identifier). DisplayName string Role auth.Role // Auto-logout countdown. The JS timer counts down from this value // in seconds and posts to /auth/logout on expiry. Resets on every // page load (JS re-initialises from this value each time). AutoLogoutSeconds int // Themes populates the nav theme dropdown: "auto", the built-in // themes, then the instance's custom themes. The selected entry is // client-side state (localStorage), so no option is marked here. Themes []ThemeOption // Timezone is the signed-in user's IANA timezone ("" = UTC), passed to // the localTime template func so timestamps render as "local (UTC)". Timezone string // RestorePending is true (admins only) when a database restore is staged and // awaiting a restart, so the nav can show a persistent reminder. Set only for // admins, since only they can act on it; clears on the next boot when the // restore is applied. RestorePending bool // UnreadNotifications is the viewer's unread in-app notification count, shown // as a nav badge. 0 hides the badge. UnreadNotifications int // TokenExpiryNotices are the expiring-access-token reminders this viewer is // permitted to see — only tokens whose editing permission the viewer holds // (global → settings admins, config → config editors), so a warning is never // shown to someone who cannot act on it. Empty hides the banner. TokenExpiryNotices []TokenExpiryNotice // Flash message for one-time user feedback. Flash string FlashKind string // "error" | "success" | "info" } BaseData is the common data available to every authenticated page template. It is embedded in page-specific data structs or passed directly when no extra fields are needed. func NewBaseData(r *http.Request, settings auth.Settings) BaseData NewBaseData builds a BaseData from the current request context. settings is read to obtain the auto-logout timeout. For authenticated users it also resolves the theme dropdown options, invoking the lazily injected custom-theme lister (see ThemeHandler.OptionsMiddleware) — the DB is only queried when a page actually renders. func (bd BaseData) WithError(msg string) BaseData WithError is shorthand for WithFlash("error", msg). func (bd BaseData) WithFlash(kind, msg string) BaseData WithFlash returns a copy of bd with a flash message attached. func (bd BaseData) WithSuccess(msg string) BaseData WithSuccess is shorthand for WithFlash("success", msg). type ChangeHistoryStore interface { Record(ctx context.Context, entityType string, entityID int64, snapshotJSON, comment string, userID *int64, username string) error List(ctx context.Context, entityType string, entityID int64, limit int) ([]*db.ChangeHistoryEntry, error) } ChangeHistoryStore records and lists change-history snapshots for an entity. db.ChangeHistoryRepo satisfies it; kept handler-local so the profile/theme handlers depend on the capability, not the repo. type ConfigHandler struct { Service *migrationconfig.Service Versions migrationconfig.VersionRepository Settings func() auth.Settings Audit *audit.Logger // optional; nil disables audit logging // PlatformTokens is the config owner's account token store. On save, a token // entered for a source is written here (keyed by platform+instance) and blanked // on the source, so the config persists no token value — the reference model's // single store of token values. It also feeds the source-row token dropdown. // Nil leaves tokens on the source as a legacy fallback. PlatformTokens PlatformTokenStore // OwnerAssigner reassigns a config's owning user — the admin claim path for an // orphaned (ownerless) config. Nil hides the owner panel on the edit form and // disables POST /configs/{id}/owner. OwnerAssigner ConfigOwnerAssigner // UserLister populates the admin owner-assignment dropdown. Nil hides the panel. UserLister ConfigUserLister } ConfigHandler handles /configs/* routes. Operators with PermManageConfigs can create, edit and delete. Operators with PermViewConfigs can list and view history. func (h *ConfigHandler) AssignOwnerPOST(w http.ResponseWriter, r *http.Request) AssignOwnerPOST reassigns a config's owner from the admin owner panel (POST /configs/{id}/owner, admin-only via PermManageUsers). It is deliberately separate from the main save so it never enters the conflict-resolution flow: claiming an orphaned config is a metadata change, not a content edit. A 404 is returned for an unknown config so existence never leaks beyond the route gate. func (h *ConfigHandler) CreatePOST(w http.ResponseWriter, r *http.Request) CreatePOST validates the submitted form and creates a new config, re-rendering the form with conflicts or errors when the save is blocked (POST /configs). func (h *ConfigHandler) DeletePOST(w http.ResponseWriter, r *http.Request) DeletePOST removes a config and redirects to the list (POST /configs/{id}/delete). func (h *ConfigHandler) ListGET(w http.ResponseWriter, r *http.Request) ListGET renders the list of all migration configs (GET /configs). func (h *ConfigHandler) NewGET(w http.ResponseWriter, r *http.Request) NewGET renders the empty config creation form (GET /configs/new). func (h *ConfigHandler) ShowGET(w http.ResponseWriter, r *http.Request) ShowGET renders the edit form for an existing config (GET /configs/{id}). func (h *ConfigHandler) UpdatePOST(w http.ResponseWriter, r *http.Request) UpdatePOST validates the submitted form and saves changes to an existing config, re-rendering with conflicts when the save is blocked (POST /configs/{id}). func (h *ConfigHandler) VersionsGET(w http.ResponseWriter, r *http.Request) VersionsGET renders the saved-version history for a config (GET /configs/{id}/versions). type ConfigLogsHandler struct { Configs migrationconfig.Repository // RunLogDir is the directory the worker writes per-config run logs into // (<log-dir>/runs). Empty disables the download. RunLogDir string Audit *audit.Logger } ConfigLogsHandler serves a config's per-run log files as one .zip, so a config that is misbehaving can be diagnosed from a single download instead of by shell access to the log directory. POST-only, matching the repo and DB-snapshot downloads: the same-origin CSRF check then guards the contents, which name every repository a config touches and carry whatever a source API said when it failed. func (h *ConfigLogsHandler) DownloadPOST(w http.ResponseWriter, r *http.Request) DownloadPOST streams every run-log file belonging to one config as a .zip (POST /configs/{id}/logs/download). The archive is written straight to the response — these are plain text files already on local disk, so there is nothing to build first and nothing worth buffering. type ConfigOwnerAssigner interface { AssignOwner(ctx context.Context, configID, ownerUserID int64) error } ConfigOwnerAssigner reassigns a config's owning user. It is the claim mechanism for an orphaned config: the owner is the account whose saved source tokens a run authenticates with, so an unclaimed config can't resolve any token. Concrete implementation: *db.MigrationConfigRepo. type ConfigUserLister interface { ListUsers(ctx context.Context) ([]*auth.User, error) } ConfigUserLister lists users to populate the admin owner-assignment dropdown. Concrete implementation: *db.UserRepo (ListUsers). type DashboardHandler struct { Configs migrationconfig.Repository Runs dashboardRunLister Queue run.QueueRepository Worker *migrate.Worker Repos *db.RepositoryRepo Settings func() auth.Settings Now func() time.Time } DashboardHandler renders the main "/" page. func (h *DashboardHandler) DashboardGET(w http.ResponseWriter, r *http.Request) DashboardGET renders the real dashboard. type DiffSection struct { Title string Changes []FieldChange } DiffSection groups field changes under a heading matching the section in the originating view (e.g. the settings form's "Email / SMTP" card). Only sections with at least one change are kept. type DryRunQueuer interface { Enqueue(ctx context.Context, entry *run.QueueEntry) error } DryRunQueuer is the slice of the worker this wizard needs: queue one run. A handler-local interface (like PlatformTokenStore) so the wizard depends on the capability rather than on *migrate.Worker — which is also what makes the queueing half of step 2 testable without standing up a whole worker. type EmailSender interface { IsConfigured() bool SendPasswordReset(ctx context.Context, toAddress, username, resetURL string, validity time.Duration) error } EmailSender is the subset of the email package's Sender interface that handlers need. Defined locally so handlers can be tested with a stub that does not import the real SMTP plumbing. type FieldChange struct { Label string Old string New string Sensitive bool } FieldChange is one changed field within a history entry: a human-readable label and the old/new display values. For sensitive fields (tokens, passwords) Old/New are non-revealing placeholders ("set"/"not set") and Sensitive is true, so the actual secret is never rendered. type FirstRunHandler struct { Service *migrationconfig.Service Settings func() auth.Settings Audit *audit.Logger // optional; nil disables audit logging // Runs and Worker back step 2's dry run. Both optional: with either unwired // the step still renders and can be skipped, so the wizard never becomes // impassable because run plumbing is missing. Runs run.Repository Worker DryRunQueuer // Queue reports runs that are waiting for the worker. Without it a run that // has been queued but not yet started is indistinguishable from no run at // all, so the step invites a second click. Queue run.QueueRepository } FirstRunHandler owns the namespace-confirmation wizard. The wizard must be completed before a real (non-dry) run may fire. Flow: GET /configs/{id}/confirm → Step 1: preview & explain GET /configs/{id}/confirm/dry-run → Step 2: run a real dry run and watch it POST /configs/{id}/confirm/dry-run → start that dry run GET /configs/{id}/confirm/lock → Step 3: typed-name lock POST /configs/{id}/confirm → complete the wizard (redirect) → /configs/{id}?flash=… → done The wizard is only reachable for configs that are NOT yet confirmed (IsConfirmed() == false). Arriving at Step 1 for an already-confirmed config redirects back to the config page with an info message. func (h *FirstRunHandler) ConfirmGET(w http.ResponseWriter, r *http.Request) ConfirmGET renders the first step of the wizard: a summary of what the config will do and what "confirming" locks in. func (h *FirstRunHandler) ConfirmPOST(w http.ResponseWriter, r *http.Request) ConfirmPOST completes the wizard: the operator must type the config name exactly (mirroring GitHub's "type the repo name to delete" UX) before the namespace is confirmed and real runs are unlocked (POST /configs/{id}/confirm). func (h *FirstRunHandler) DryRunGET(w http.ResponseWriter, r *http.Request) DryRunGET renders step 2: a real dry run over the new config, so the operator sees what it would actually do before locking the namespace in. The wizard's first step analyses the config; this one EXERCISES it. A namespace typo or a token that resolves to nothing shows up here as a failed listing rather than as a surprise on the first real run. func (h *FirstRunHandler) DryRunPOST(w http.ResponseWriter, r *http.Request) DryRunPOST queues the wizard's dry run and returns to step 2 to watch it. Always a dry run, whatever the config's own DryRun setting says: the point is to preview an unconfirmed config, and a real run on one is refused elsewhere anyway. The form field "only_if_missing" marks the automatic start that carries the operator from step 1 into step 2: a config that has never been run gets its dry run without having to ask for it, while one that already has a run (or one queued) is carried straight through instead of queueing a duplicate. That path also stays silent when runs are unavailable — the step is skippable, so merely continuing must not look like a failure. func (h *FirstRunHandler) DryRunStatusJSON(w http.ResponseWriter, r *http.Request) DryRunStatusJSON reports the wizard dry run's progress as JSON (GET /configs/{id}/confirm/dry-run/status), so the step can refresh itself without reloading the page. It exists specifically so the page does NOT reload on a timer. A reload is a top-level navigation, and the auth middleware counts navigations as genuine user activity — so a self-reloading page keeps its session alive forever, reinstating exactly the idle-logout hole that background traffic was made non-navigational to close. A fetch is not a navigation, so polling here cannot slide the session. func (h *FirstRunHandler) LockGET(w http.ResponseWriter, r *http.Request) LockGET renders step 3 of the wizard: the typed-name confirmation form. Reachable via GET /configs/{id}/confirm/lock from the forward button on step 2 (the dry run), and re-rendered by ConfirmPOST on a name mismatch. func (h *FirstRunHandler) RequireConfirmed(next http.Handler) http.Handler RequireConfirmed is middleware for future run-trigger routes. If the requested config is not yet confirmed it redirects the operator to the confirmation wizard instead of allowing the run. The configID is read from the chi URL param "id". type ForgejoVersioner interface { ServerVersion(ctx context.Context) (string, error) } ForgejoVersioner is the slice of *forgejo.Client the Forgejo probe uses. type GlobalSettingsHandler struct { Service *globalconfig.Service Settings func() auth.Settings // DB is the live database connection, used by BackupPOST to run // VACUUM INTO for the downloadable snapshot. Nil disables the backup // endpoint (BackupPOST 500s), which is acceptable in tests that do // not exercise it. DB *sql.DB // DataDir is the directory the transient snapshot file is written to // before streaming — the service-owned data directory, same // filesystem as the live DB, so the copy never lands in a // world-readable temp dir. Empty falls back to the system temp dir. DataDir string // BackupDir is the directory holding scheduled/stored backups, listed and // served inline in the settings page's backups section (backups.go). BackupDir string // Audit records the backup_created event. Nil-safe (no-op). Audit *audit.Logger // Themes reads the custom + pending themes for the settings page's themes // section. Optional; nil hides the section. Themes themeSectionStore } GlobalSettingsHandler handles /settings routes. Requires PermManageSettings (admin only). func (h *GlobalSettingsHandler) BackupDeletePOST(w http.ResponseWriter, r *http.Request) BackupDeletePOST removes a stored backup (POST /settings/backups/delete, form field "name"). func (h *GlobalSettingsHandler) BackupDownloadPOST(w http.ResponseWriter, r *http.Request) BackupDownloadPOST streams a stored backup as an attachment (POST /settings/backups/download, form field "name"). POST so the same-origin CSRF check guards the (whole-database) download, mirroring BackupPOST. func (h *GlobalSettingsHandler) BackupNowPOST(w http.ResponseWriter, r *http.Request) BackupNowPOST creates a stored backup immediately and prunes under the current retention policy (POST /settings/backups/run). func (h *GlobalSettingsHandler) BackupPOST(w http.ResponseWriter, r *http.Request) BackupPOST streams a consistent snapshot of the SQLite database to the client as a file download (POST /settings/backup). It snapshots to a transient file in DataDir via internal/backup, streams that file with an attachment disposition, then removes it. POST (not GET) so the same-origin CSRF check applies: the snapshot is the entire database, so it must not be fetchable cross-site. All fallible work happens before any response body is written, so an early failure can still redirect back to /settings with a flash; once streaming starts the status line is committed and errors are only logged. func (h *GlobalSettingsHandler) BackupSchedulePOST(w http.ResponseWriter, r *http.Request) BackupSchedulePOST saves the backup schedule/retention config, preserving the rest of global settings (POST /settings/backups). On validation failure it redirects back with the error flash. func (h *GlobalSettingsHandler) BackupsGET(w http.ResponseWriter, r *http.Request) BackupsGET redirects to the consolidated settings page's backups section (GET /settings/backups) — scheduled-backup management moved there. Kept as a redirect so existing bookmarks/links still resolve. func (h *GlobalSettingsHandler) RestoreCancelPOST(w http.ResponseWriter, r *http.Request) RestoreCancelPOST discards a staged or armed restore (POST /settings/restore/cancel). func (h *GlobalSettingsHandler) RestoreConfirmPOST(w http.ResponseWriter, r *http.Request) RestoreConfirmPOST arms the staged restore after a typed confirmation: it atomically renames the staging dir to the pending dir, which the daemon applies on its next start (POST /settings/restore/confirm). func (h *GlobalSettingsHandler) RestoreGET(w http.ResponseWriter, r *http.Request) RestoreGET renders the restore page: the upload form, or — when a restore is already armed — instructions to restart (GET /settings/restore). func (h *GlobalSettingsHandler) RestoreUploadPOST(w http.ResponseWriter, r *http.Request) RestoreUploadPOST receives an uploaded backup (and optional pepper), stages it, validates it, and renders the preview/confirm page. Nothing is applied here — staging is inert until RestoreConfirmPOST arms it (POST /settings/restore/upload). func (h *GlobalSettingsHandler) SettingsGET(w http.ResponseWriter, r *http.Request) SettingsGET renders the settings form with the current configuration and the ten most recent saves (GET /settings). func (h *GlobalSettingsHandler) SettingsPOST(w http.ResponseWriter, r *http.Request) SettingsPOST validates and persists the submitted settings form, preserving existing secrets when their fields are left blank (POST /settings). type HelpHandler struct { Manual *help.Manual Settings func() auth.Settings } HelpHandler serves repoman's in-app user manual: a grouped index and one page per topic. The content is the pre-rendered Markdown held in the help.Manual (loaded once at startup), so these handlers only assemble view models — they never render Markdown per request. Help text is not secret, so every topic is readable by any signed-in user; admin-only topics carry a badge, not a gate. func (h *HelpHandler) IndexGET(w http.ResponseWriter, r *http.Request) IndexGET renders the help landing page: every topic grouped by area, in the canonical display order (GET /help). func (h *HelpHandler) TopicGET(w http.ResponseWriter, r *http.Request) TopicGET renders one help topic by slug, with the grouped index alongside as a sidebar (GET /help/{slug}). An unknown slug is a 404 — the slug space is the fixed topic registry, so a miss is a bad link, not a server error. type InventoryHandler struct { Repos *db.RepositoryRepo Configs migrationconfig.Repository // for owner-scoping rows to the viewer Settings func() auth.Settings } InventoryHandler renders /inventory. func (h *InventoryHandler) InventoryGET(w http.ResponseWriter, r *http.Request) InventoryGET renders the /inventory page listing all tracked repos. type IssueWebhookHandler struct { // Deriver recomputes the per-config webhook secret the hook was provisioned // with, so deliveries can be verified without storing a secret. Deriver *crypto.WebhookSecretDeriver // Configs loads the migration config (its sources + tokens) addressed by the // {configID} path segment. Configs migrationconfig.Repository // Links is the issue/comment mapping store. Links *db.IssueSyncRepo // Global returns the live global settings (Forgejo connection). The sync // engine is built per request from these so it always targets the currently // configured Forgejo instance. Global func(ctx context.Context) (*globalconfig.Settings, error) // OwnerTokens loads a config owner's personal source tokens, the fallback in // source-token resolution (per-source → config override → owner token). Nil or // an ownerless config means no owner fallback. OwnerTokens func(ctx context.Context, userID int64) (map[string]string, error) } IssueWebhookHandler ingests issue-hub webhooks. It is anonymous to the auth middleware and exempt from the same-origin CSRF check (the senders are machines, not browsers) — every request is instead gated by a signature verified against the per-config secret derived from the pepper. The Forgejo endpoint propagates hub edits OUT to each linked source via the sync engine. See docs/issue-hub.md. func (h *IssueWebhookHandler) ForgejoPOST(w http.ResponseWriter, r *http.Request) ForgejoPOST handles POST /webhooks/forgejo/{configID}. It verifies the Forgejo HMAC signature, parses the issue/issue_comment event, and pushes the hub edit out to every source the issue is linked to. Always acks with 200 once the signature is valid (logging per-link failures) so Forgejo does not retry-storm; the reconcile poll is the correctness backstop. A bad signature is 401, a malformed request 400. func (h *IssueWebhookHandler) SourcePOST(w http.ResponseWriter, r *http.Request) SourcePOST handles POST /webhooks/source/{configID}/{platform}. It verifies the platform's signature (GitHub/Gitea HMAC, GitLab token), parses the issue/comment event, resolves the single link for that source issue, and pulls the edit into the Forgejo hub. Like ForgejoPOST it acks 200 once verified; bad signature 401, malformed 400. The repoman-write echo (our own API write to the source firing a webhook back) is broken by the baseline check inside the engine. type NotificationStore interface { ListForUser(ctx context.Context, userID int64, limit int) ([]*db.Notification, error) UnreadCount(ctx context.Context, userID int64) (int, error) MarkRead(ctx context.Context, userID, id int64) error MarkAllRead(ctx context.Context, userID int64) error } NotificationStore is the subset of db.NotificationRepo the handler needs. type NotificationsHandler struct { Store NotificationStore Settings func() auth.Settings } NotificationsHandler serves the per-user in-app notification feed. func (h *NotificationsHandler) ListGET(w http.ResponseWriter, r *http.Request) ListGET renders the signed-in user's notifications, newest first (GET /notifications). func (h *NotificationsHandler) MarkAllReadPOST(w http.ResponseWriter, r *http.Request) MarkAllReadPOST marks all of the user's notifications read (POST /notifications/read-all). func (h *NotificationsHandler) MarkReadPOST(w http.ResponseWriter, r *http.Request) MarkReadPOST marks one of the user's notifications read (POST /notifications/{id}/read). type PersonalThemeLister interface { ListPersonalThemes(ctx context.Context, ownerUserID int64) ([]theme.Theme, error) } PersonalThemeLister reads a user's personal themes for the profile page's "My themes" section. db.ThemeRepo satisfies it; kept handler-local so the profile page depends on the capability, not the whole theme store. type PlatformTokenStore interface { List(ctx context.Context, userID int64) ([]db.UserPlatformToken, error) Set(ctx context.Context, userID int64, platform, instanceURL, namespace, ownerType, token string) error Delete(ctx context.Context, userID int64, platform, instanceURL, namespace string) error } PlatformTokenStore is the subset of db.UserPlatformTokenRepo the profile page needs: list a user's saved (platform, instance, namespace, token) records and add/update or delete one by (platform, instance, namespace). Kept as a handler-local interface so handlers depends on the capability, not the repo. This is the reference model's single store of token values — a config source resolves its token from here. type ProfileHandler struct { Users auth.UserAdminRepository // GetByID, UpdatePasswordHash, ClearTOTP TOTPRepo auth.TOTPUserRepository // SetTOTP (via CompleteEnrollment) Sessions *auth.SessionManager // DeleteAllForUserExcept after pw change Pepper auth.Pepper // VerifyPassword / HashPassword SecLog *security.Logger // optional; nil disables security logging Audit *audit.Logger // optional; nil disables audit logging Settings func() auth.Settings // PlatformTokens stores this user's personal source access tokens (the // multi-tenant per-user credentials). Optional; nil hides the tokens section. PlatformTokens PlatformTokenStore // PersonalThemes lists the signed-in user's personal themes for the // profile page's "My themes" section. Optional; nil hides the section. // (Theme create/edit/import/submit still live under /profile/themes, // served by ThemeHandler — this is only the read for the profile page.) PersonalThemes PersonalThemeLister // History records and lists profile change history. Optional; nil disables // both recording and the "Change history" section. History ChangeHistoryStore } ProfileHandler serves the authenticated user's self-service profile — a single consolidated page (GET /profile) carrying two-factor status, password change, timezone, source access tokens, and personal themes. Every action operates on the user from the request context (set by the Auth middleware) — there is no cross-user access here. func (h *ProfileHandler) PasswordChangeGET(w http.ResponseWriter, r *http.Request) PasswordChangeGET renders the password-change form (GET /profile/password). func (h *ProfileHandler) PasswordChangePOST(w http.ResponseWriter, r *http.Request) PasswordChangePOST validates and applies a password change, then revokes every other session while keeping the current device logged in (POST /profile/password). func (h *ProfileHandler) PlatformTokenDeletePOST(w http.ResponseWriter, r *http.Request) PlatformTokenDeletePOST removes one saved (platform, instance) token (POST /profile/tokens/delete). Operates only on the signed-in user. func (h *ProfileHandler) PlatformTokensPOST(w http.ResponseWriter, r *http.Request) PlatformTokensPOST adds or updates one of the user's source access tokens (POST /profile/tokens) from the account page's add form: a (platform, instance URL, token) triple. A blank URL means the platform's default (cloud) instance. Operates only on the signed-in user. This is the single input path that grows the account store (the config form's per-source token field also routes here). func (h *ProfileHandler) ProfileDetailsPOST(w http.ResponseWriter, r *http.Request) ProfileDetailsPOST updates the signed-in user's display name and email (POST /profile/details). The display name is free-form; changing the email requires re-entering the current password (a credential change), and a blank email clears it. An email already used by another account is rejected. func (h *ProfileHandler) ProfileGET(w http.ResponseWriter, r *http.Request) ProfileGET renders the consolidated self-service profile page: two-factor status, password change, timezone, source access tokens, and personal themes (GET /profile). func (h *ProfileHandler) TimezonePOST(w http.ResponseWriter, r *http.Request) TimezonePOST saves the user's preferred IANA timezone (POST /profile/timezone). An empty value or "UTC" stores UTC; any other value must be a resolvable IANA name. func (h *ProfileHandler) TwoFADisablePOST(w http.ResponseWriter, r *http.Request) TwoFADisablePOST disables 2FA after re-verifying the user's password (POST /profile/2fa/disable). func (h *ProfileHandler) TwoFAEnrolGET(w http.ResponseWriter, r *http.Request) TwoFAEnrolGET starts 2FA enrolment by generating a fresh secret and rendering the QR-code wizard (GET /profile/2fa/enrol). func (h *ProfileHandler) TwoFAEnrolPOST(w http.ResponseWriter, r *http.Request) TwoFAEnrolPOST verifies the submitted code against the candidate secret and, on success, completes enrolment and shows the recovery codes (POST /profile/2fa/enrol). type RepoDownloadHandler struct { Repos repoInventory Global settingsLoader Archiver repoMirrorWriter Audit *audit.Logger // DataDir is the directory transient per-repo zips are written to before // streaming — the service-owned data directory, so the copy never lands in // a world-readable temp dir. Empty falls back to the system temp dir. DataDir string } RepoDownloadHandler streams synced repositories as restorable .zip mirrors (every branch/tag/commit). Admin-only — the routes are gated behind PermBackupRestore at the router. Each download clones the all-refs mirror from Forgejo on demand via the Archiver, so it reflects current state. func (h *RepoDownloadHandler) DownloadAllPOST(w http.ResponseWriter, r *http.Request) DownloadAllPOST streams every synced repository as one container .zip whose entries are each repository's own .zip mirror (POST /inventory/download). A repo that fails to clone gets a ".FAILED.txt" marker entry and the rest continue, so one unreachable repository does not abort the whole backup. func (h *RepoDownloadHandler) DownloadOnePOST(w http.ResponseWriter, r *http.Request) DownloadOnePOST streams a single repository (by inventory ID) as a .zip of its full Git mirror (POST /inventory/{id}/download). The mirror is built into a transient file first, so a clone failure redirects cleanly instead of handing the client a truncated archive. type RunHandler struct { Runs run.Repository Events run.EventRepository Configs migrationconfig.Repository Queue run.QueueRepository // lists queued-but-unstarted entries for the history view Worker *migrate.Worker Settings func() auth.Settings Audit *audit.Logger // optional; nil disables audit logging } RunHandler handles all run-related routes: POST /configs/{id}/runs — trigger a new run GET /configs/{id}/runs — run history for one config GET /configs/{id}/runs/{runID} — live monitor page GET /configs/{id}/runs/{runID}/events — SSE stream POST /configs/{id}/runs/{runID}/cancel — cancel an in-progress run func (h *RunHandler) CancelPOST(w http.ResponseWriter, r *http.Request) CancelPOST requests cancellation of the currently active run, rejecting the request when that run is not the active one (POST /configs/{id}/runs/{runID}/cancel). func (h *RunHandler) EventsSSE(w http.ResponseWriter, r *http.Request) EventsSSE streams a run's log as Server-Sent Events: historical events for a finished run, or a live subscription to the worker broadcaster for an in-progress one (GET /configs/{id}/runs/{runID}/events). func (h *RunHandler) HistoryGET(w http.ResponseWriter, r *http.Request) HistoryGET renders the recent run history for a config (GET /configs/{id}/runs). func (h *RunHandler) LogPageJSON(w http.ResponseWriter, r *http.Request) LogPageJSON returns one page of a run's log as JSON for the lazy monitor view (GET /configs/{id}/runs/{runID}/log?before=<id>&limit=<n>): the newest `limit` lines older than `before` (before omitted/0 → newest overall), oldest-first. The client loads just enough to fill the viewport on open and pages older lines in on scroll-up, so a multi-thousand-line run never ships its whole log at once. func (h *RunHandler) MonitorGET(w http.ResponseWriter, r *http.Request) MonitorGET renders the monitor page shell for a single run (GET /configs/{id}/runs/{runID}). It deliberately ships NO log events: the client fetches the log lazily via LogPageJSON (a viewport-worth at a time, older lines on scroll-up), so opening a long run never blocks the browser. func (h *RunHandler) TriggerPOST(w http.ResponseWriter, r *http.Request) TriggerPOST enqueues a new run for a config, refusing a real run on an unconfirmed config (POST /configs/{id}/runs). type SMTPTester interface { TestConnection(ctx context.Context) error } SMTPTester is the slice of email.Sender the SMTP probe uses. type SourceLister interface { ListRepos(ctx context.Context) ([]*source.Repo, error) } SourceLister is the slice of source.Client the source probe uses. type TestConnectionHandler struct { Settings *globalconfig.Service NewForgejo func(baseURL, token string, skipTLS bool) ForgejoVersioner NewSMTP func(cfg email.Config) (SMTPTester, error) NewSource func(platform, namespace, token string, ownerType migrationconfig.OwnerType, instURL string) (SourceLister, error) // Now supplies the clock the expiry countdown is measured against; nil means // time.Now. Injected so a test can assert a stable "in N days". Now func() time.Time // OwnerTokens loads a user's personal source tokens, used as the blank-token // fallback when probing a source so a user can test their own saved token. // Optional; nil means no personal-token fallback. OwnerTokens func(ctx context.Context, userID int64) (map[string]string, error) } TestConnectionHandler backs the "test connection" buttons on the settings and config-editor pages. Each handler builds a client from the submitted form values — so operators can verify credentials *before* saving — and reports the verdict as JSON. Secret fields left blank fall back to the saved value, mirroring the settings form's "leave blank to keep" behaviour: the operator can re-test a saved Forgejo token or SMTP password without retyping it. Status codes: - 400 — the request to the probe itself was malformed or missing a required field (URL, platform, namespace). Nothing was attempted. - 200 — the probe ran; the verdict is in the body. ok=true on success, ok=false with a human-readable error otherwise. The client constructors are injected so tests can supply fakes; NewTestConnectionHandler wires the real forgejo/email/source ones. func NewTestConnectionHandler(settings *globalconfig.Service) *TestConnectionHandler NewTestConnectionHandler returns a handler wired with the production client constructors. func (h *TestConnectionHandler) TestForgejoPOST(w http.ResponseWriter, r *http.Request) TestForgejoPOST probes the Forgejo connection using the submitted form values (falling back to saved URL/token), reporting the server version on success (POST /settings/test-forgejo). func (h *TestConnectionHandler) TestOwnerTokenPOST(w http.ResponseWriter, r *http.Request) TestOwnerTokenPOST probes one of the signed-in user's SAVED account tokens (POST /profile/tokens/test), identified by the (platform, instance URL, namespace) triple that keys the account store — the value itself is never sent to the browser and never comes back from it. It answers the question the Account page could not: a token listed there is just a row, and "is it still good, and whose is it" needed a migration run to find out. Shares the probe with the config editor's source test, so both screens report the same facts in the same shape. func (h *TestConnectionHandler) TestSMTPPOST(w http.ResponseWriter, r *http.Request) TestSMTPPOST probes the SMTP connection using the submitted form values (falling back to the saved password), mapping the single TLS toggle to SMTPS or STARTTLS (POST /settings/test-smtp). func (h *TestConnectionHandler) TestSourcePOST(w http.ResponseWriter, r *http.Request) TestSourcePOST probes a source platform with the submitted credentials (falling back to the signed-in user's saved token), reporting the repo count plus what the platform says about the token itself — the account it authenticates as, its scopes, its expiry (POST /configs/test-source). With no namespace it reports the token's identity alone. That is the outbound token-override case: such a row names a platform and a credential and has nothing to enumerate, and refusing to probe it left the only tokens repoman uses for WRITING as the only ones that could not be tested. type ThemeHandler struct { Store themeStore Settings func() auth.Settings // Audit records theme_created/updated/deleted events. Nil-safe. Audit *audit.Logger // Notifier raises the theme-submission notifications (admin alerted on // submit; submitter told the result). Nil disables notifications. Notifier ThemeNotifier // Users lists accounts to resolve admin recipients for submission alerts. // Nil disables the admin alert. Users UserLister // History records and lists theme change history. Optional; nil disables // both recording and the editor's "Change history" section. Keyed by theme // id, so a theme's history travels with it across personal→pending→global. History ChangeHistoryStore } ThemeHandler serves the generated /themes.css stylesheet (all users, pre-auth included so the login page is themed) and the admin-only theme management under /settings/themes: create, edit, delete, import a shared .css theme file, and export a theme back to that format. func (h *ThemeHandler) AcceptPOST(w http.ResponseWriter, r *http.Request) AcceptPOST promotes a pending theme to global with the admin's final name and notifies the submitter (POST /settings/themes/{id}/accept). func (h *ThemeHandler) BuilderGET(w http.ResponseWriter, r *http.Request) BuilderGET renders the standalone theme builder available to ANY signed-in user (GET /theme-builder): color pickers for every theme variable, a live preview, and client-side export/import of the .css theme format. Users can build a theme and export it to send to an admin, who imports it under Settings → Themes — so this page needs no admin rights and saves nothing server-side. func (h *ThemeHandler) CreatePOST(w http.ResponseWriter, r *http.Request) CreatePOST validates the submitted form and creates the theme (POST /settings/themes). func (h *ThemeHandler) DeletePOST(w http.ResponseWriter, r *http.Request) DeletePOST removes a custom theme (POST /settings/themes/{id}/delete). Users who had it selected fall back to the default theme client-side. func (h *ThemeHandler) DenyPOST(w http.ResponseWriter, r *http.Request) DenyPOST returns a pending theme to its submitter as a personal theme and notifies them (POST /settings/themes/{id}/deny). func (h *ThemeHandler) EditGET(w http.ResponseWriter, r *http.Request) EditGET renders the form pre-filled with an existing theme (GET /settings/themes/{id}). func (h *ThemeHandler) ExportGET(w http.ResponseWriter, r *http.Request) ExportGET streams a theme as a downloadable .css file in the same format the import accepts (GET /settings/themes/{id}/export). Themes are not secrets — every user receives them via /themes.css — so a GET is fine. func (h *ThemeHandler) ImportPOST(w http.ResponseWriter, r *http.Request) ImportPOST creates a theme from an uploaded .css theme file (POST /settings/themes/import). The name comes from the optional form field, falling back to the file's base name. func (h *ThemeHandler) ListGET(w http.ResponseWriter, r *http.Request) ListGET redirects to the consolidated settings page's themes section (GET /settings/themes) — theme management moved there. Kept as a redirect so existing bookmarks/links still resolve. func (h *ThemeHandler) MyStylesheetGET(w http.ResponseWriter, r *http.Request) MyStylesheetGET serves the signed-in user's PERSONAL themes as a per-user stylesheet (GET /themes/me.css, authenticated, private-cached) — kept separate from the anonymous global /themes.css so one user's themes never leak to others. An admin additionally gets every pending theme so the management page's live preview can render them. func (h *ThemeHandler) NewGET(w http.ResponseWriter, r *http.Request) NewGET renders an empty theme form pre-filled with the light theme's values as a starting point (GET /settings/themes/new). func (h *ThemeHandler) OptionsMiddleware(next http.Handler) http.Handler OptionsMiddleware injects a lazy custom-theme lister into the request context so NewBaseData can populate the nav dropdown on whichever page renders — without every handler taking a theme dependency, and without querying the DB for requests that never render a page (SSE, downloads). func (h *ThemeHandler) PersonalCreatePOST(w http.ResponseWriter, r *http.Request) PersonalCreatePOST saves a new personal theme owned by the signed-in user (POST /profile/themes). func (h *ThemeHandler) PersonalDeletePOST(w http.ResponseWriter, r *http.Request) PersonalDeletePOST removes one of the user's personal themes (POST /profile/themes/{id}/delete). func (h *ThemeHandler) PersonalEditGET(w http.ResponseWriter, r *http.Request) PersonalEditGET renders the editor for one of the user's personal themes (GET /profile/themes/{id}). func (h *ThemeHandler) PersonalExportGET(w http.ResponseWriter, r *http.Request) PersonalExportGET streams one of the user's personal themes as a .css file (GET /profile/themes/{id}/export). func (h *ThemeHandler) PersonalImportPOST(w http.ResponseWriter, r *http.Request) PersonalImportPOST creates a personal theme from an uploaded .css file (POST /profile/themes/import) — the same validation gate as the admin import. func (h *ThemeHandler) PersonalListGET(w http.ResponseWriter, r *http.Request) PersonalListGET redirects to the consolidated profile page's "My themes" section (GET /profile/themes) — the personal-theme list moved there. Kept as a redirect so existing bookmarks/links still resolve. func (h *ThemeHandler) PersonalNewGET(w http.ResponseWriter, r *http.Request) PersonalNewGET renders the personal-theme editor pre-filled with the light theme as a starting point (GET /profile/themes/new). func (h *ThemeHandler) PersonalUpdatePOST(w http.ResponseWriter, r *http.Request) PersonalUpdatePOST saves edits to one of the user's personal themes (POST /profile/themes/{id}). func (h *ThemeHandler) StylesheetGET(w http.ResponseWriter, r *http.Request) StylesheetGET serves the generated stylesheet containing every theme (GET /themes.css, anonymous — the login page links it). The content is ETag'd with its SHA-256 so unchanged themes answer 304 to revalidations. func (h *ThemeHandler) SubmitPOST(w http.ResponseWriter, r *http.Request) SubmitPOST submits one of the user's personal themes for admin evaluation with an optional suggested name (POST /profile/themes/{id}/submit). The admin alert notification is raised by the Notifier when wired. func (h *ThemeHandler) UpdatePOST(w http.ResponseWriter, r *http.Request) UpdatePOST validates the submitted form and updates an existing theme (POST /settings/themes/{id}). type ThemeNotifier interface { NotifyUsers(ctx context.Context, userIDs []int64, kind, title, body, link string) Notify(ctx context.Context, userID int64, kind, title, body, link string) error } ThemeNotifier raises a notification to a set of users. notify.Service satisfies it (NotifyUsers); kept as an interface so handlers don't import the notify implementation directly and tests can record calls. type ThemeOption struct { Value string Label string } ThemeOption is one nav-dropdown entry: Value is the data-theme attribute value the client sets ("auto", a built-in slug, or "custom-<id>"), Label the display name. type TokenExpiryNotice struct { Platform string Location string ExpiresAt time.Time Expired bool OwnerUserID int64 } TokenExpiryNotice is one expiring-token reminder for the nav banner. Location is a human description of where the token lives; OwnerUserID is the user who may edit it — the banner's security boundary, so a token is shown only to its owner (and to admins, who may edit any token). type TwoFactorHandler struct { Sessions *auth.SessionManager Users auth.UserRepository TOTPRepo auth.TOTPUserRepository SecLog *security.Logger } TwoFactorHandler handles the second-factor step of login. It is invoked after LoginPOST set a session cookie with pending_2fa=1. The session middleware redirects every other URL to /auth/2fa until the user provides a valid code. func (h *TwoFactorHandler) TwoFactorGET(w http.ResponseWriter, r *http.Request) TwoFactorGET renders the 2FA-input form. func (h *TwoFactorHandler) TwoFactorPOST(w http.ResponseWriter, r *http.Request) TwoFactorPOST validates the submitted code (TOTP or recovery) and promotes the session out of pending_2fa state. type UserAdminHandler struct { Repo auth.UserAdminRepository Pepper auth.Pepper Sessions *auth.SessionManager Settings func() auth.Settings SecLog *security.Logger // optional; nil disables security logging Audit *audit.Logger // optional; nil disables audit logging // History lists any user's profile change history so an admin can oversee // changes across the instance. Optional; nil hides the per-user History view. History ChangeHistoryStore } UserAdminHandler handles the /admin/users/* routes. All routes require PermManageUsers (enforced by the router middleware). func (h *UserAdminHandler) CreatePOST(w http.ResponseWriter, r *http.Request) CreatePOST validates and creates a new user, re-rendering the form with an error on validation failure or a taken username (POST /admin/users). func (h *UserAdminHandler) ListGET(w http.ResponseWriter, r *http.Request) ListGET renders the table of all users (GET /admin/users). func (h *UserAdminHandler) LockPOST(w http.ResponseWriter, r *http.Request) LockPOST hard-locks a user and invalidates their sessions, refusing to lock the last active admin (POST /admin/users/{id}/lock). func (h *UserAdminHandler) NewGET(w http.ResponseWriter, r *http.Request) NewGET renders the empty new-user form (GET /admin/users/new). func (h *UserAdminHandler) Reset2FAPOST(w http.ResponseWriter, r *http.Request) Reset2FAPOST clears a user's TOTP enrolment and invalidates their sessions, forcing re-login (POST /admin/users/{id}/reset-2fa). func (h *UserAdminHandler) ResetPasswordPOST(w http.ResponseWriter, r *http.Request) ResetPasswordPOST sets a one-time temporary password (forcing a change on next login) and shows it once (POST /admin/users/{id}/reset-password). func (h *UserAdminHandler) SetForgejoOwnerPOST(w http.ResponseWriter, r *http.Request) SetForgejoOwnerPOST pins (or clears) a user's Forgejo target owner (POST /admin/users/{id}/forgejo-owner). Non-admins may only create configs targeting this owner; admins are unrestricted. func (h *UserAdminHandler) UnlockPOST(w http.ResponseWriter, r *http.Request) UnlockPOST clears a lock on a user account (POST /admin/users/{id}/unlock). func (h *UserAdminHandler) UpdateRolePOST(w http.ResponseWriter, r *http.Request) UpdateRolePOST changes a user's role, refusing to demote the last active admin (POST /admin/users/{id}/role). func (h *UserAdminHandler) UserHistoryGET(w http.ResponseWriter, r *http.Request) UserHistoryGET renders the profile change history for one user so an admin can audit it (GET /admin/users/{id}/history). The history reuses the same snapshot diff as the user's own profile page. type UserLister interface { ListUsers(ctx context.Context) ([]*auth.User, error) } UserLister lists user accounts (for resolving notification recipients).