Files
gitea-mobile/internal/handlers/handlers.go
T
agent-company 37ddfb128b fix: vendor htmx.min.js locally instead of loading from CDN
Download htmx.org v1.9.10 into static/htmx.min.js and update all
references (layout.html, handlers.go fallback page, sw.js precache
list) to use the local copy. This enables the PWA to work fully
offline since the service worker can now cache htmx from the same
origin.

Bump service worker cache version to v2 so existing installations
pick up the new asset list.

Closes leeworks-agents/gitea-mobile#17

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 06:03:46 +00:00

453 lines
14 KiB
Go

// Package handlers implements HTTP handlers for the Gitea Mobile application.
package handlers
import (
"fmt"
"html/template"
"log/slog"
"net/http"
"strconv"
"gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/config"
giteaclient "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/gitea"
"gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/middleware"
)
// Handler holds shared dependencies for all HTTP handlers.
type Handler struct {
Config *config.Config
Client *giteaclient.Client
}
// NewHandler creates a new Handler with the given config and client.
func NewHandler(cfg *config.Config, client *giteaclient.Client) *Handler {
return &Handler{
Config: cfg,
Client: client,
}
}
// RegisterRoutes registers all HTTP routes on the given ServeMux.
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Health endpoint.
mux.HandleFunc("GET /health", h.Health)
// Dashboard / triage.
mux.HandleFunc("GET /", h.Dashboard)
// Issues.
mux.HandleFunc("GET /issues", h.ListIssues)
mux.HandleFunc("POST /issues", h.CreateIssue)
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/labels", h.ApplyLabels)
// Pull requests.
mux.HandleFunc("GET /pulls", h.ListPulls)
mux.HandleFunc("POST /pulls/{owner}/{repo}/{index}/review", h.SubmitReview)
// Settings (handled separately for auth bypass).
settingsHandler := &SettingsHandler{
SessionSecret: h.Config.SessionSecret,
SecureCookies: true,
}
mux.HandleFunc("/settings", settingsHandler.ServeHTTP)
// Static files.
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
}
// isHTMX returns true if the request is an HTMX partial request.
func isHTMX(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
// getToken extracts the user's Gitea API token from request context.
func getToken(r *http.Request) string {
return middleware.TokenFromContext(r.Context())
}
// getUserOrgs returns the list of org names the user belongs to.
func (h *Handler) getUserOrgs(r *http.Request) []string {
token := getToken(r)
if token == "" {
return nil
}
orgs, err := h.Client.ListOrgs(r.Context(), token)
if err != nil {
slog.Error("failed to list orgs", "error", err)
return nil
}
var names []string
for _, org := range orgs {
names = append(names, org.Name)
}
return names
}
// Health handles GET /health for Kubernetes probes.
func (h *Handler) Health(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "ok")
}
// basePage is the full HTML wrapper used for non-HTMX requests.
var basePage = template.Must(template.New("base").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#161b22">
<link rel="manifest" href="/static/manifest.json">
<link rel="apple-touch-icon" href="/static/icon-192.png">
<title>{{.Title}} — Gitea Mobile</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js"></script>
</head>
<body>
<div class="content" id="main-content">
{{.Content}}
</div>
<nav class="bottom-nav">
<a href="/" {{if eq .ActiveTab "dashboard"}}class="active"{{end}}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/></svg>
Dashboard
</a>
<a href="/issues" {{if eq .ActiveTab "issues"}}class="active"{{end}}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Issues
</a>
<a href="/pulls" {{if eq .ActiveTab "pulls"}}class="active"{{end}}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 009 9"/></svg>
PRs
</a>
<a href="/settings" {{if eq .ActiveTab "settings"}}class="active"{{end}}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
Settings
</a>
</nav>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/static/sw.js').catch(function(err) {
console.log('SW registration failed:', err);
});
}
</script>
</body>
</html>`))
type pageData struct {
Title string
ActiveTab string
Content template.HTML
}
// renderPage renders either a full page (for regular requests) or just the
// content fragment (for HTMX requests).
func renderPage(w http.ResponseWriter, r *http.Request, title, activeTab string, content string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if isHTMX(r) {
fmt.Fprint(w, content)
return
}
data := pageData{
Title: title,
ActiveTab: activeTab,
Content: template.HTML(content),
}
if err := basePage.Execute(w, data); err != nil {
slog.Error("template render error", "error", err)
}
}
// Dashboard handles GET / — the triage queue.
func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
// Only handle exact root path.
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
token := getToken(r)
orgs := h.getUserOrgs(r)
if len(orgs) == 0 {
renderPage(w, r, "Dashboard", "dashboard",
`<h1>Dashboard</h1><p class="empty">No organizations found. Check your token permissions.</p>`)
return
}
queue, err := h.Client.GetTriageQueue(r.Context(), token, orgs)
if err != nil {
slog.Error("failed to get triage queue", "error", err)
renderPage(w, r, "Dashboard", "dashboard",
`<h1>Dashboard</h1><p class="empty">Error loading triage queue.</p>`)
return
}
if len(queue) == 0 {
renderPage(w, r, "Dashboard", "dashboard",
`<h1>Dashboard</h1><p class="empty">No items need attention. Nice work!</p>`)
return
}
content := `<h1>Dashboard</h1>`
for _, item := range queue {
typeBadge := `<span class="type-badge type-issue">issue</span>`
if item.Type == "pull" {
typeBadge = `<span class="type-badge type-pull">PR</span>`
}
labels := ""
for _, l := range item.Labels {
color := "#8b949e"
switch l {
case "P1":
color = "#f85149"
case "P2":
color = "#d29922"
case "P3":
color = "#58a6ff"
}
labels += fmt.Sprintf(`<span class="label" style="color:%s;border:1px solid %s">%s</span>`, color, color, template.HTMLEscapeString(l))
}
content += fmt.Sprintf(`<div class="card">
<div class="card-title">%s %s</div>
<div class="card-meta">%s/%s #%d %s</div>
</div>`, typeBadge, template.HTMLEscapeString(item.Title),
template.HTMLEscapeString(item.RepoOwner),
template.HTMLEscapeString(item.RepoName),
item.Number, labels)
}
renderPage(w, r, "Dashboard", "dashboard", content)
}
// ListIssues handles GET /issues.
func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
orgs := h.getUserOrgs(r)
if len(orgs) == 0 {
renderPage(w, r, "Issues", "issues",
`<h1>Issues</h1><p class="empty">No organizations found.</p>`)
return
}
issues, err := h.Client.ListAllIssues(r.Context(), token, orgs)
if err != nil {
slog.Error("failed to list issues", "error", err)
renderPage(w, r, "Issues", "issues",
`<h1>Issues</h1><p class="empty">Error loading issues.</p>`)
return
}
if len(issues) == 0 {
renderPage(w, r, "Issues", "issues",
`<h1>Issues</h1><p class="empty">No open issues found.</p>`)
return
}
content := `<h1>Issues</h1>`
for _, issue := range issues {
labels := ""
for _, l := range issue.Labels {
labels += fmt.Sprintf(`<span class="label" style="color:#%s;border:1px solid #%s">%s</span>`,
l.Color, l.Color, template.HTMLEscapeString(l.Name))
}
assignee := ""
if issue.Assignee != nil {
assignee = fmt.Sprintf(` &middot; %s`, template.HTMLEscapeString(issue.Assignee.Login))
}
content += fmt.Sprintf(`<div class="card">
<div class="card-title">%s</div>
<div class="card-meta">%s/%s #%d %s%s</div>
</div>`, template.HTMLEscapeString(issue.Title),
template.HTMLEscapeString(issue.RepoOwner),
template.HTMLEscapeString(issue.RepoName),
issue.Number, labels, assignee)
}
renderPage(w, r, "Issues", "issues", content)
}
// ListPulls handles GET /pulls.
func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
orgs := h.getUserOrgs(r)
if len(orgs) == 0 {
renderPage(w, r, "Pull Requests", "pulls",
`<h1>Pull Requests</h1><p class="empty">No organizations found.</p>`)
return
}
prs, err := h.Client.ListAllPullRequests(r.Context(), token, orgs)
if err != nil {
slog.Error("failed to list pull requests", "error", err)
renderPage(w, r, "Pull Requests", "pulls",
`<h1>Pull Requests</h1><p class="empty">Error loading pull requests.</p>`)
return
}
if len(prs) == 0 {
renderPage(w, r, "Pull Requests", "pulls",
`<h1>Pull Requests</h1><p class="empty">No open pull requests found.</p>`)
return
}
content := `<h1>Pull Requests</h1>`
for _, pr := range prs {
labels := ""
for _, l := range pr.Labels {
labels += fmt.Sprintf(`<span class="label" style="color:#%s;border:1px solid #%s">%s</span>`,
l.Color, l.Color, template.HTMLEscapeString(l.Name))
}
stats := fmt.Sprintf(`<span style="color:#3fb950">+%d</span> <span style="color:#f85149">-%d</span>`, pr.Additions, pr.Deletions)
mergeStatus := ""
if pr.Mergeable {
mergeStatus = `<span style="color:#3fb950;font-size:0.7rem;">mergeable</span>`
}
content += fmt.Sprintf(`<div class="card">
<div class="card-title"><span class="type-badge type-pull">PR</span> %s</div>
<div class="card-meta">%s/%s #%d %s %s %s</div>
</div>`, template.HTMLEscapeString(pr.Title),
template.HTMLEscapeString(pr.RepoOwner),
template.HTMLEscapeString(pr.RepoName),
pr.Number, labels, stats, mergeStatus)
}
renderPage(w, r, "Pull Requests", "pulls", content)
}
// CreateIssue handles POST /issues.
func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
owner := r.FormValue("owner")
repo := r.FormValue("repo")
title := r.FormValue("title")
body := r.FormValue("body")
if owner == "" || repo == "" || title == "" {
http.Error(w, "owner, repo, and title are required", http.StatusBadRequest)
return
}
issue, err := h.Client.CreateIssue(r.Context(), token, owner, repo, title, body, nil)
if err != nil {
slog.Error("failed to create issue", "error", err)
http.Error(w, "failed to create issue", http.StatusInternalServerError)
return
}
if isHTMX(r) {
w.Header().Set("HX-Redirect", fmt.Sprintf("/issues/%s/%s/%d", owner, repo, issue.Number))
w.WriteHeader(http.StatusOK)
return
}
http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, issue.Number), http.StatusSeeOther)
}
// ApplyLabels handles POST /issues/{owner}/{repo}/{index}/labels.
func (h *Handler) ApplyLabels(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
owner := r.PathValue("owner")
repo := r.PathValue("repo")
indexStr := r.PathValue("index")
index, err := strconv.ParseInt(indexStr, 10, 64)
if err != nil {
http.Error(w, "invalid issue index", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
var labelIDs []int64
for _, idStr := range r.Form["label_id"] {
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
continue
}
labelIDs = append(labelIDs, id)
}
if len(labelIDs) == 0 {
http.Error(w, "no labels specified", http.StatusBadRequest)
return
}
if err := h.Client.ApplyLabel(r.Context(), token, owner, repo, index, labelIDs); err != nil {
slog.Error("failed to apply labels", "error", err, "owner", owner, "repo", repo, "index", index)
http.Error(w, "failed to apply labels", http.StatusInternalServerError)
return
}
if isHTMX(r) {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `<span style="color:#3fb950">Labels applied</span>`)
return
}
http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther)
}
// SubmitReview handles POST /pulls/{owner}/{repo}/{index}/review.
func (h *Handler) SubmitReview(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
owner := r.PathValue("owner")
repo := r.PathValue("repo")
indexStr := r.PathValue("index")
index, err := strconv.ParseInt(indexStr, 10, 64)
if err != nil {
http.Error(w, "invalid PR index", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
reviewType := r.FormValue("event") // APPROVED, REQUEST_CHANGES, COMMENT
body := r.FormValue("body")
if reviewType == "" {
http.Error(w, "review event type is required", http.StatusBadRequest)
return
}
if err := h.Client.SubmitReview(r.Context(), token, owner, repo, index, reviewType, body); err != nil {
slog.Error("failed to submit review", "error", err, "owner", owner, "repo", repo, "index", index)
http.Error(w, "failed to submit review", http.StatusInternalServerError)
return
}
if isHTMX(r) {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `<span style="color:#3fb950">Review submitted</span>`)
return
}
http.Redirect(w, r, fmt.Sprintf("/pulls/%s/%s/%d", owner, repo, index), http.StatusSeeOther)
}