Web (Dashboard API)
Function-level reference for /api/* routes, OAuth flows, sessions, alerts, usage, and embedded SPA wiring.
Files in internal/web/. The package owns every /api/* route, the OAuth flow, and the embedded SPA.
handler.go
Wires every HTTP route and owns the central Handler struct that every route method is bound to. It embeds the frontend SPA assets, registers every API endpoint under /api, OAuth redirect endpoints, the incoming git-webhook receiver, and SPA fallback routing. The auth middleware authenticates only via the wh_session cookie (DB-backed sessions).
NewHandler(cfg, db, bus, dispatcher, selector, stats, publisher, alertMgr) *Handler— constructs the handler, initialises the git-provider registry and three rate limiters (apiRateLimit,oauthRateLimit,webhookRateLimit); takes thealerts.Managerso alert routes can resolve/acknowledge.(h) Mount(r *gin.Engine)— registers all routes on the Gin engine; embeds static SPA assets and sets the SPA catch-all. The inbound git webhook (POST /v1/webhooks/:provider/:uuid) is wrapped inwebhookRateLimit(60/min/IP).securityHeaders(c)— middleware; CSP, HSTS, referrer-policy, COOP, sanitisedPermissions-Policy. The CSPscript-srcdirective includes asha256-...hash for the inline pre-React theme bootstrap script inassets/index.html(nounsafe-inline).noStore(c)— middleware;Cache-Control: no-store,Pragma: no-cacheon/api.(h) auth(c)— middleware; resolveswh_sessioncookie viaLookupDBSession, sets"user"and"session_id"in context; clears stale cookies.currentUser(c) *User— extracts authenticated user from context.(h) getMe(c)—GET /api/me— profile (incl.theme_pref), app list (incl.repo_url+auto_deploy), plan limits, linked providers, platform config.(h) deploy(c)—POST /api/deploy— validates repo URL, framework, env keys, plan limits; picks worker; creates app+deployment+job; async registers provider webhook.(h) getApp(c)—GET /api/apps/:id— metadata, latest build logs, active job.(h) listAppDeployments(c)—GET /api/apps/:id/deployments— last 30.(h) getDeploymentLogs(c)—GET /api/deployments/:id/logs— single deployment detail with ownership check.(h) restartApp(c)—POST /api/apps/:id/restart— queues redeploy (optionally pinned to a commit), pushes job.(h) startApp(c)—POST /api/apps/:id/start—startjob pinned to the current worker; flips astoppedapp back torunningwithout rebuilding the image.(h) stopApp(c)—POST /api/apps/:id/stop—stopjob pinned to current worker.(h) deleteApp(c)—DELETE /api/apps/:id— async deletes provider webhook, queues cleanup, hard-deletes app row, publishesapp.deleted. Audited asapp_delete.(h) clearGitToken(c)—DELETE /api/git-token/:provider— removes stored OAuth token. Audited asgit_token_revoke.(h) loadAppForUser(c) (*App, *User, bool)— shared helper; loads app by UUID, verifies ownership.
deploy, restartApp, startApp, stopApp, and deleteApp each call recordAuditEvent with the matching app_* kind so the activity feed shows every state transition.
Types
JobDispatcher—PushJob(workerID, jobID int)interface.WorkerSelector—SelectAvailableWorker(resources) (id, zone, err).StatsReader—AppStats(appID) (cpuPct, memBytes, memLimit, updatedAt, ok).Handler— central struct.
domains_api.go
Custom hostnames per app. The user proves control via a TXT record at _wisehosting.<hostname>; once verified, the host enters the worker's Traefik HTTP provider config on the next 2-second poll without restarting any container.
domainDTO— response shape, includingverification_host(the FQDN the user must add a TXT record on) andverification_token.toDomainDTO(d, appName, appSubdomain)— DB row → DTO.newDomainToken() string—wh-+ 24 random hex bytes.validateHostname(raw) (string, error)— lowercase, dotted labels, label charset[a-z0-9-], ≤253 total / ≤63/label, no leading/trailing dash. Rejects anything that could survive into a TraefikHost()rule unescaped (no backticks/quotes/whitespace/scheme/path).(h) listDomains(c)—GET /api/domains— all of the user's domains plusstats: {total, verified, pending}.(h) createDomain(c)—POST /api/domains— body{hostname, app_uuid}. 409 on hostname already-claimed; 400 ifhostname == app.subdomain(system-routed already). Audited asdomain_create.(h) verifyDomain(c)—POST /api/domains/:id/verify— DNS TXT lookup over_wisehosting.<hostname>with an 8-second context. On match: stampsverified_at, audits asdomain_verify. On miss: storeslast_check_errorand returns{verified: false, error}.(h) deleteDomain(c)—DELETE /api/domains/:id— audited asdomain_delete. Routing drops on the next Traefik HTTP-provider poll.
oauth.go
OAuth2 flow for git providers (GitHub, GitLab, Bitbucket, Codeberg) — used only for linking after sign-in, never for primary auth. Issues HMAC-signed state tokens bound to a nonce cookie (CSRF defence).
(h) cookieSecure() bool— returns true ifwebapp.urlis HTTPS.issueOAuthBind(c, secure) (string, error)— random nonce +wh_oauth_bindcookie (10-min TTL).clearOAuthBind(c, secure)— expires bind cookie.bindHash(secret, nonce) string— HMAC-SHA256 of"bind:"+nonce, 32 hex chars.(h) redirectURIFor(providerID) string— provider callback URL.(h) oauthStartDispatch(c)—GET /oauth/:provider/start— Google callsstartGoogleOAuth; otherwise git OAuth redirect with signed state.(h) oauthCallbackDispatch(c)—GET /oauth/:provider/callback— verifies state+bind cookie, exchanges code, fetches profile, saves provider token.(h) startGitOAuth(c)—POST /api/git-oauth/start— authenticated; bind nonce + signed state, returns auth URL as JSON.(h) listMyRepos(c)—GET /api/repos— uses stored token; supports query, pagination, and an optional?org=to scope to one of the user's GitHub orgs (delegates toListReposScoped).(h) listMyOrgs(c)—GET /api/orgs?provider=github— callsListOrgswith the stored token; surfaces{login, avatar_url, description}to drive the deploy dialog's org picker.(h) listProviders(c)—GET /api/providers— registered providers +configuredflags.signOAuthState(userID, secret, nonce) (string, error)— HMAC-signed state blob.verifyOAuthState(state, secret, nonce) (int64, error)— verifies state+bind+expiry.renderOAuthPage(c, title, body, ok)— minimal self-contained HTML result page.oauthCSS(tone)/htmlEscape(s)— helpers.SignCompletionToken/VerifyCompletionToken— vestigial helpers retained from the prior Telegram deep-link flow; not wired to any current route.
google.go
Google OAuth2 sign-in (the only sign-in path). Direct calls to oauth2.googleapis.com/token and googleapis.com/oauth2/v2/userinfo, then upserts the user, creates a DB-backed session, and audits the login.
(h) startGoogleOAuth(c)— Google consent URL with signed state + bind nonce.(h) googleOAuthCallback(c)— verifies state, exchanges code, upserts user. Branches ontotp_enabled: enrolled users get apending_totpsrow, thewh_2fa_pendingcookie, and a redirect to/2fa/challenge; everyone else falls straight through toCreateDBSession+wh_session. Always firesrecordAuditEvent("login", …).(h) logout(c)—POST /api/logout— revokes the current session row, clears cookie, firesrecordAuditEvent("logout", …).exchangeGoogleCode(...)— POST token endpoint, GET userinfo; returns profile fields.
twofa.go
Per-user TOTP enrollment and the post-OAuth challenge step. Opt-in: a user with totp_enabled = false never sees a challenge.
(h) twoFASetup(c)—POST /api/me/2fa/setup— generates a fresh TOTP secret (32 bytes base32), stores it AES-GCM-encrypted on the user, returns theotpauth://provisioning URI plus a base64 PNG QR for the dashboard. Does not fliptotp_enabled— that happens on first successful verify.(h) twoFAVerify(c)—POST /api/me/2fa/verify—{code: "123456"}. On success: setstotp_enabled = true, stampstotp_verified_at, generates 10 single-use backup codes, replaces any priorbackup_codesrows, returns the plaintext list once. Audits as2fa_enabled.(h) twoFADisable(c)—POST /api/me/2fa/disable— clearstotp_secret, flipstotp_enabled = false, deletes allbackup_codes. Audits as2fa_disabled.(h) twoFAChallengeStatus(c)—GET /api/2fa/challenge/status— checks thewh_2fa_pendingcookie and returns{valid: bool, expires_at}. Drives the SPA's challenge page so an expired link bounces back to login cleanly.(h) twoFAChallenge(c)—POST /api/2fa/challenge— body{code, type: "totp"|"backup"}. Validates against the encrypted secret orConsumeBackupCode. On success: deletes the pending row, callsCreateDBSession(..., twoFactor: true), setswh_session, clearswh_2fa_pending, audits withmetadata: {2fa: true}(orbackup_code: true). On failure: returns 401 without burning the pending row, so a typo doesn't kick the user back through Google. Backup-code path is rate-limited to 3 attempts per 15 minutes per pending token.
session.go
DB-backed sessions. Cookie body is pure entropy (no embedded user-id, no signature) — the row in sessions is the source of truth, and we only ever store sha256(token) so a DB leak doesn't hand out live sessions.
hashToken(token) string— sha256 hex of the cookie body.newSessionToken() string— random opaque token (sent to the browser, never persisted).CreateDBSession(c, db, userID, twoFactor bool) (token, sessionID, err)— inserts a row with IP, geo (LookupIP), user agent (capped at 512 bytes),created_at,last_seen_at, andtwo_factor_verifiedset from the caller (true only on the post-/api/2fa/challengepath).LookupDBSession(c, db, token) (userID, sessionID, err)— resolves cookie to row; bumpslast_seen_atat most once per minute. Treats sessions whoselast_seen_atis older thanSessionTTLas expired (rolling 30-day window — idle sessions die off, active ones renew transparently).clientIP(c) string/capUA(ua) string— request helpers.setSessionCookie(c, token, secure)—wh_sessionHttpOnly SameSite=Lax, 30-day Max-Age.clearSessionCookie(c)— expires cookie.
Constants: SessionCookieName = "wh_session", SessionTTL = 30 * 24h. A goroutine in main.go calls db.PruneRevokedSessions(SessionTTL) every 6 hours to hard-delete revoked or stale rows.
sessions_api.go
Endpoints behind Account → Security in the dashboard, plus the audit log used by the activity feed.
(h) recordAuditEvent(c, userID, kind, metadata)— appends toaudit_events(renamed fromlogin_eventsin migration0005). Onkind == "login", firesaccount.login_new_devicewebhook when this(user_id, ip)pair has never been seen.(h) listSessions(c)—GET /api/me/sessions— returns[]sessionDTOwithid,ip,city,country,user_agent,created_at,last_seen_at,current(bool), and a parseddevice/browser/ostriple.(h) revokeSession(c)—DELETE /api/me/sessions/:id— refuses to revoke the current session (caller should hit/api/logout).(h) revokeOtherSessions(c)—POST /api/me/sessions/revoke-others— bulk-revoke + audit each.(h) listActivity(c)—GET /api/me/activity?limit=(max 200) — returns[]auditEventDTOnewest first across every recorded kind (login/logout/session_revoked, app lifecycle, env edits, domain edits, git token revoke).parseUA(ua)— cheap pattern matching to surface device/browser/os; deliberately library-free.
sessionDTO, auditEventDTO — response shapes.
iplookup.go
Best-effort IP → city/country resolver used by CreateDBSession and recordAuditEvent.
LookupIP(ctx, ip) IPGeo— returns{IP, City, Country, CountryCode}; usesip-api.com's free no-auth endpoint with a 24h per-IP cache and a ~2s ceiling. Empty result for private/loopback/invalid IPs and on any provider failure — never blocks login.
preferences.go
(h) getPreferences(c)—GET /api/me/preferences— returns{theme_pref}("system"|"light"|"dark").(h) updatePreferences(c)—PATCH /api/me/preferences— validates and persiststheme_pref.
env.go
Per-app env var CRUD, auto-deploy toggle, build-spec updates. All routes verify ownership via loadAppForUser.
(h) listAppEnv(c)—GET /api/apps/:id/env.(h) setAppEnv(c)—PUT /api/apps/:id/env— upserts (max 100, max 8 KiB/value, key matches^[A-Z][A-Z0-9_]*$).(h) deleteAppEnv(c)—DELETE /api/apps/:id/env/:key.(h) updateAppBuild(c)—PATCH /api/apps/:id/build— framework/root_dir/install/build/start.(h) setAutoDeploy(c)—PUT /api/apps/:id/auto-deploy.
env_workspace.go
Workspace-wide env var view + bulk .env import. No new DB table — reads/writes the existing app_env_vars rows across all of a user's apps.
(h) listWorkspaceEnv(c)—GET /api/env— flat list of every env var across the user's apps, plusstats: {total, projects, projects_total, last_updated}and anappssummary list. Each entry:{id, app_id, app_uuid, app_name, key, value, created_at, updated_at}.(h) bulkImportEnv(c)—POST /api/apps/:id/env/import— body{content: ".env file text", replace: bool}; callsparseDotenv, validates keys, optionally deletes keys not in the upload (replace: true).parseDotenv(content) (map, []errors)— line-by-line dotenv parser; returns parsed map and per-line error strings.
alert_rules_api.go
Per-app alert rule CRUD. Backed by the app_alert_rules table; the threshold engine in internal/alerts polls these on a 30s loop.
(h) listAppAlertRules(c)—GET /api/apps/:id/alert-rules— auto-creates default rules on first call viaEnsureAppAlertRules.(h) updateAppAlertRules(c)—PUT /api/apps/:id/alert-rules— upserts an array of{kind, enabled, threshold, sustain_minutes, severity}. ClosedvalidRuleKindsset:cpu,memory,network,disk,offline,crashloop,deployment_failed.
alerts_api.go
Alert feed surfaced on the dashboard /alerts page.
(h) listAlerts(c)—GET /api/alerts?status=&kind=&app_id=&limit=(limit max 500) — returns alerts plusstats: {total, active, warning, resolved_30d}.(h) updateAlert(c)—PATCH /api/alerts/:id— body{action: "acknowledge"|"resolve"|"reopen"}. CallsManager.Resolveetc. for delegation to the alert manager.iso(t)— nullable timestamp → optional ISO8601 string.
alertDTO — response shape (kind, severity, status, source, title, message, app, timestamps).
ratelimit.go
In-process sliding-window rate limiter; GC every 5 min.
newRateLimiter(limit, window)— constructor.(r) allow(key) bool— checks + records hit; periodic GC.(h) apiRateLimit(c)— keys byuser:<id>if authed, elseip:<addr>.(h) oauthRateLimit(c)— IP only.(h) webhookRateLimit(c)— 60 requests / minute / IP, applied toPOST /v1/webhooks/:provider/:uuidso a misbehaving git provider can't drown the inbound side.
regions.go
(h) listRegions(c)—GET /api/regions— all workers minus stale (no heartbeat in 2 min);used_percent = max(cpu%, mem%);has_capacity = used < 95.
runtime_logs.go
(h) runtimeLogs(c)—GET /api/apps/:id/logs/runtime— up to 500 (default) / 1000 (max) lines for one app withnext_sincecursor.(h) runtimeLogsAll(c)—GET /api/logs/runtime— up to 1000/2000 lines across all user apps, optionalappfilter.
usage.go
(h) getUsage(c)—GET /api/usage— iterates user apps, callsh.stats.AppStatsLivefor running apps, aggregates totals.(h) getAppLiveStats(c)—GET /api/apps/:id/live-stats— single-app live snapshot (CPU%, mem, mem-limit, net Mbps, net total bytes, disk Mbps).
appUsageDTO, usageDTO — response shapes.
usage_timeseries.go
Reads the usage_samples table that internal/usage.Recorder populates every minute, rolled up to the requested granularity for the dashboard's Usage page.
(h) getUsageTimeseries(c)—GET /api/usage/timeseries— query params:from/to(RFC3339),granularity(auto|5m|1h|1d), optionalapp_id.parseRange(c)— bounded parse; max range 365 days.resolveGranularity(g, from, to) int—autorule: ≤24h → 5-min, ≤7d → 1-hour, else 1-day.resolveScope(c, h, userID) ([]int, name, error)— single-app vs all-user-apps.rollupBuckets(raw, sec, names, userScope)— sums same-bucket samples (avg-of-avgs approximation; max-of-maxes; cumulativenet_bytes_delta).sumByApp(raw, names) []timeseriesAppSum— totals for the donut chart.
timeseriesBucket, timeseriesAppSum, timeseriesResponse — response shapes ({granularity, from, to, buckets, apps}). 90-day retention is enforced by the recorder, not the endpoint.
webhooks.go
Provider-side (git) webhook lifecycle + inbound push handler.
generateWebhookSecret() string— 32 random hex bytes.(h) registerProviderWebhook(app, gitToken)— async;p.RegisterWebhookthen save hook ID.(h) deleteProviderWebhook(app, gitToken)— async;p.DeleteWebhook.(h) gitWebhook(c)—POST /v1/webhooks/:provider/:uuid— verifies HMAC, skips non-push and auto-deploy=false, extracts commit, queues redeploy, pushes job.extractPushCommit(providerID, body) (commit, branch)— parses provider-specific push payloads.
webhooks_api.go
CRUD for outbound user-configurable webhooks. Targets are either signed-HTTPS endpoints or Shoutrrr-style URLs for any of 22 supported chat channels.
shoutrrrSchemes— closed set of accepted non-HTTPS schemes:discord,slack,telegram,ntfy,gotify,pushover,pushbullet,matrix,teams,mattermost,rocketchat,googlechat,zulip,lark,wecom,opsgenie,ifttt,join,bark,twilio,signal,mqtt/mqtts,generic.validateWebhookURL(raw) error— acceptshttps://or any Shoutrrr scheme; rejects plainhttp://. Logs the specific reason server-side; always returns the opaqueerrInvalidWebhookURL("invalid url") to the client.toWebhookDTO(h, appName)— DB row to API shape.(h) listUserWebhooks(c)—GET /api/webhooks— list + stats summary + valid event names.(h) createWebhook(c)—POST /api/webhooks— returns DTO + plain-text secret (only this once).(h) buildWebhook(...)— validates name, URL (viavalidateWebhookURL), event names (supports*andprefix.*wildcards), optional app ownership; generates secret.newWebhookSecret() string—whsec_+ 32 hex bytes.(h) updateWebhook(c)—PATCH /api/webhooks/:id.(h) deleteWebhook(c)—DELETE /api/webhooks/:id.(h) testWebhook(c)—POST /api/webhooks/:id/test— fires one synchronousEventTestdelivery viaDispatcher.TestDeliver, persists the resultingwebhook_deliveriesrow, and updates the webhook'slast_status_code/last_success/last_delivery_at. Useful for flipping a freshly-created hook from "pending first delivery" to a real status without waiting for an actual event.(h) listWebhookDeliveries(c)—GET /api/webhooks/:id/deliveries— last 50 attempts.
webhookDTO — response shape.
recent.go
(h) listRecentDeployments(c)—GET /api/deployments/recent— last 8 deployments across user's apps.
frameworks.go
(h) listFrameworks(c)—GET /api/frameworks—frameworks.All().