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, mailer) *Handler— constructs the handler, initialises the git-provider registry, the browser event hub, and five rate limiters (apiLimiter,oauthLimiter,backupLimiter3/15min,webhookLimiter60/min,totpLimiter5/min); takes thealerts.Managerso alert routes can resolve/acknowledge, and themail.Mailerfor new-device login email alerts.(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).LiveAppStats—CPUPct,MemBytes,MemLimit,NetMBps,NetTotalBytes,DiskMBps,UpdatedAt.StatsReader—AppStats(appID) (cpuPct, memBytes, memLimit, updatedAt, ok),AppStatsLive(appID) (LiveAppStats, ok),SubscribeStats(appID) (<-chan LiveAppStats, cancel).AppEvent—Type string,Data map— push notification sent to browser clients via WebSocket.EventBus—PublishUser(userID int, evt AppEvent)interface.Handler— central struct; holds the event hub, mailer, and five rate limiters.
domains_api.go
Custom hostnames per app. The user proves control via a TXT record at _wisehosting.<hostname>; once verified, the host enters the proxy's Traefik HTTP provider config on the next 5-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"and a new-device detection (HasAuditEventForIPreturns false), fires theaccount.login_new_devicewebhook and sends a new-device alert email viamail.Mailer.SendNewDeviceAlert(if SMTP is configured). The email includes device/browser/OS info, IP geolocation, and a one-click HMAC-signed session revoke link (72-hour expiry).(h) revokeByToken(c)—GET /api/session/revoke-token?token=...— unauthenticated endpoint. Verifies the HMAC-signed token from the new-device email and revokes the session. Returns a self-contained HTML page (dark-themed, no SPA dependency) with success/error feedback.(h) listSessions(c)—GET /api/me/sessions— returns[]sessionDTOwithid,ip,city,country,user_agent,created_at,last_seen_at,current(bool),two_factor_verified, and a parseddevice/browser/ostriple.(h) revokeSession(c)—DELETE /api/me/sessions/:id— refuses to revoke the current session (caller should hit/api/logout). Audits assession_revoked.(h) revokeOtherSessions(c)—POST /api/me/sessions/revoke-others— bulk-revoke + audit.(h) listActivity(c)—GET /api/me/activity?limit=(max 200) — returns[]loginEventDTOnewest 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, loginEventDTO — 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.
event_hub.go
Browser WebSocket push hub. Delivers AppEvent notifications to connected browser clients in real time — used to push deployment status changes, violation alerts, and other state transitions to the dashboard without polling.
browserEventHub— Per-user connection registry (map[int][]*browserConn), capped at 20 connections per user.newBrowserEventHub() *browserEventHub— Constructor.(h) PublishUser(userID int, evt AppEvent)— JSON-marshals the event and fans out to all of the user's open WebSocket connections. Non-blocking send with drop on full channel.(h) add(userID, conn) bool— Registers a connection; returns false if the per-user cap (20) is reached.(h) remove(userID, conn)— Unregisters a connection.(h *Handler) wsEvents(c)—GET /api/ws/events— upgrades to WebSocket, registers the browser connection in the event hub, runs the standard ping/pong lifecycle (30 s ping, 65 s pong timeout), and delivers events from thesendchannel.(h *Handler) EventBus() EventBus— Returns the event hub somain.gocan wire it to the API hub for cross-subsystem event delivery.
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— delegates tobuildUsageSnapshot.(h) buildUsageSnapshot(apps []database.App) usageDTO— iterates apps, callsh.stats.AppStatsLivefor running apps, aggregates totals. Shared bygetUsage(HTTP) andwsUsage(WebSocket).(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.
ws_handler.go
WebSocket handlers for real-time streaming to the browser dashboard. All three handlers use the same ping/pong lifecycle (30 s ping interval, 65 s pong timeout).
newBrowserUpgrader(allowedOrigin) websocket.Upgrader— Constructs an upgrader with origin check for browser WebSocket connections.(h) wsLogs(c)—GET /api/ws/logs— streams runtime log lines over WebSocket. Optional?app=<uuid>to filter by one app, optional?since=<seq>to replay buffered lines on reconnect. On fresh connect (since=0), replays persisted lines from theruntime_logstable viaLoadRuntimeLogsMulti, then replays in-memory buffered lines, then subscribes to the log bus for live push. Sends aninitmessage with the full projects list for filter dropdown population.(h) wsUsage(c)—GET /api/ws/usage— streams aggregate resource usage over WebSocket. Subscribes toSubscribeStatsfor each of the user's running apps, coalesces updates into a single trigger channel, rebuilds and sends the fullusageDTOsnapshot on each trigger. Replaces the former 10-second HTTP polling.(h) wsStats(c)—GET /api/apps/:id/ws/stats— streams live resource stats for a single app. Sends current snapshot immediately, then subscribes for push updates viaSubscribeStats.
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().