Files
ghglance/internal/web/server.go
T
tiennm99 537c87c458 feat(web): show cards inline for quick viewing and accept the visitor's own token
The web UI is for a quick look at a profile, not for hosting embeddable
images. Cards are inlined into /u/<user> as data: images, the per-card
route is gone, and the page no longer offers copy links or Markdown
snippets. The page itself stays shareable.

Visitors can again paste their own token, with a button that opens
GitHub's new-token page with the needed scopes ticked. A pasted token
takes precedence over sign-in, follows the same ownership, privacy and
cooldown rules, is never stored or logged, and is not revoked. The server
still uses no token of its own.
2026-10-07 19:15:15 +07:00

491 lines
15 KiB
Go

// Package web serves the ghglance web UI: a form that queues card
// generation for any GitHub user on the visitor's own GitHub access (a
// "Sign in with GitHub" token or a token they paste), and pages that show
// the stored cards for quick viewing. Cards are inlined into the page as
// data: URIs; there is no URL to link to or embed a card.
package web
import (
"context"
"embed"
"encoding/base64"
"encoding/json"
"fmt"
"html/template"
"io"
"io/fs"
"log"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/tiennm99/ghglance/internal/card"
"github.com/tiennm99/ghglance/internal/theme"
)
//go:embed templates static
var assets embed.FS
// maxFormBytes bounds a form post; the real form is well under 1 KiB.
const maxFormBytes = 16 << 10
// Submission rate per client IP: a burst of 5, then one every 2 minutes.
const (
submitBurst = 5
submitInterval = 2 * time.Minute
)
const (
// pageCSP takes GitHub's origin as an extra form-action source: the
// sign-in form is redirected to GitHub's consent page, and browsers
// check redirects of a form submission against form-action too.
// img-src allows data: for the cards, which the user page inlines; an
// SVG loaded through <img> runs no scripts and fetches nothing.
pageCSP = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; " +
"connect-src 'self'; form-action 'self' %s; base-uri 'none'; frame-ancestors 'none'"
// maxCardBytes bounds one stored card read into the user page; real
// cards are a few tens of KiB at most.
maxCardBytes = 1 << 20
)
// Config configures the web server.
type Config struct {
Addr string
DataDir string
Cooldown time.Duration
// Retention is how long generated cards are kept before they are
// deleted (0 = forever).
Retention time.Duration
Workers int
// JobTimeout bounds one generation job (0 = no limit).
JobTimeout time.Duration
// Fetcher overrides the GitHub fetch; nil uses the real API.
Fetcher Fetcher
// OAuth configures "Sign in with GitHub". It is required; visitors may
// paste their own token instead of signing in, but the server never
// uses a token of its own.
OAuth OAuthConfig
}
// Server holds the store, job queue and handlers.
type Server struct {
cfg Config
store *Store
queue *Queue
limiter *rateLimiter
pages map[string]*template.Template
static http.Handler
csp string
oauth *oauthApp
logins *pendingLogins
}
// New opens the data directory and starts the job workers.
func New(cfg Config) (*Server, error) {
if cfg.Fetcher == nil {
cfg.Fetcher = githubFetcher{}
}
oauth, err := newOAuthApp(cfg.OAuth)
if err != nil {
return nil, err
}
store, err := OpenStore(cfg.DataDir)
if err != nil {
return nil, err
}
pages, err := parsePages()
if err != nil {
store.Close()
return nil, err
}
staticFS, err := fs.Sub(assets, "static")
if err != nil {
store.Close()
return nil, err
}
s := &Server{
cfg: cfg,
store: store,
limiter: newRateLimiter(submitBurst, submitInterval),
pages: pages,
static: http.FileServerFS(staticFS),
csp: fmt.Sprintf(pageCSP, oauth.webOrigin()),
oauth: oauth,
logins: newPendingLogins(),
}
s.queue = newQueue(store, cfg.Fetcher, oauth.revoke, cfg.JobTimeout, cfg.Cooldown, cfg.Workers)
return s, nil
}
// Close stops the workers and releases the data directory.
func (s *Server) Close() {
s.queue.Stop()
s.store.Close()
}
// Run serves until ctx is cancelled, then drains in-flight requests and
// stops the job workers. Queued and running jobs are abandoned; nothing
// half-rendered is ever published.
func Run(ctx context.Context, cfg Config) error {
s, err := New(cfg)
if err != nil {
return err
}
defer s.Close()
log.Printf("sign in with GitHub callback %s", s.oauth.redirectURI)
ln, err := net.Listen("tcp", cfg.Addr)
if err != nil {
return err
}
srv := &http.Server{
Handler: s.Handler(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 2 * time.Minute,
}
errc := make(chan error, 1)
go func() { errc <- srv.Serve(ln) }()
log.Printf("serving on %s, data in %s", ln.Addr(), s.store.dir)
go s.expireLoop(ctx)
select {
case err := <-errc:
return err
case <-ctx.Done():
}
log.Printf("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
}
// Handler returns the routed, hardened HTTP handler.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", s.handleIndex)
mux.HandleFunc("POST /auth/start", s.handleAuthStart)
mux.HandleFunc("GET /auth/callback", s.handleAuthCallback)
mux.HandleFunc("GET /u/{user}", s.handleUser)
mux.HandleFunc("GET /u/{user}/status", s.handleStatus)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
fmt.Fprintln(w, "ok")
})
mux.Handle("GET /static/", http.StripPrefix("/static/", s.withCache(s.static, "public, max-age=3600")))
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
s.renderMessage(w, http.StatusNotFound, "Page not found", "There is nothing at this address.")
})
cop := http.NewCrossOriginProtection()
return commonHeaders(cop.Handler(mux), s.csp)
}
func commonHeaders(next http.Handler, csp string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Content-Security-Policy", csp)
next.ServeHTTP(w, r)
})
}
func (s *Server) withCache(next http.Handler, value string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", value)
next.ServeHTTP(w, r)
})
}
// pageData feeds every template.
type pageData struct {
Title string
Heading string
Message string
Error string
Form formValues
Login string
Key string
Meta *Meta
Job JobStatus
Notice string
CooldownLeft string
ExpiresIn string
Themes []string
Theme string
Cards []cardView
}
// cardView is one card on the user page. Src is a data: URI of the stored
// SVG, so the page carries the card itself and no card URL.
type cardView struct {
Name string
Src template.URL
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
s.render(w, http.StatusOK, "index", pageData{Title: "ghglance", Form: defaultForm()})
}
// readSubmission parses, rate-limits and validates a generation form. On
// failure it has already written the response.
func (s *Server) readSubmission(w http.ResponseWriter, r *http.Request) (submission, formValues, bool) {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
s.render(w, http.StatusBadRequest, "index", pageData{Title: "ghglance", Error: "The form could not be read.", Form: defaultForm()})
return submission{}, formValues{}, false
}
sub, form, err := parseSubmission(r.PostForm.Get)
if !s.limiter.Allow(clientKey(r)) {
w.Header().Set("Retry-After", strconv.Itoa(int(submitInterval.Seconds())))
s.render(w, http.StatusTooManyRequests, "index", pageData{
Title: "ghglance",
Error: "Too many submissions from your address. Wait a couple of minutes and try again.",
Form: form,
})
return submission{}, formValues{}, false
}
if err != nil {
s.render(w, http.StatusBadRequest, "index", pageData{Title: "ghglance", Error: capitalize(err.Error()) + ".", Form: form})
return submission{}, formValues{}, false
}
return sub, form, true
}
// enqueue queues sub and redirects to the user's page, adding notice when
// one is given. It reports whether a new job took the submission's token;
// when not, the caller still owns it. The cooldown is checked by the job,
// once it knows whose token it holds: a token for the target account
// skips it.
func (s *Server) enqueue(w http.ResponseWriter, r *http.Request, sub submission, form formValues, notice string) bool {
target := "/u/" + url.PathEscape(userKey(sub.Login))
if s.queue.Status(sub.Login).Active() {
http.Redirect(w, r, target+"?notice=pending", http.StatusSeeOther)
return false
}
created, err := s.queue.Submit(sub)
if err != nil {
s.render(w, http.StatusServiceUnavailable, "index", pageData{Title: "ghglance", Error: capitalize(err.Error()) + ".", Form: form})
return false
}
switch {
case !created:
target += "?notice=pending"
case notice != "":
target += "?notice=" + url.QueryEscape(notice)
}
http.Redirect(w, r, target, http.StatusSeeOther)
return created
}
func (s *Server) handleUser(w http.ResponseWriter, r *http.Request) {
login := r.PathValue("user")
if !validUsername(login) {
s.renderMessage(w, http.StatusNotFound, "Not a GitHub username", "GitHub usernames use letters, digits and single hyphens, up to 39 characters.")
return
}
meta, err := s.store.Meta(login)
if err != nil && !isNotExist(err) {
log.Printf("read meta %s: %v", userKey(login), err)
s.renderMessage(w, http.StatusInternalServerError, "Something went wrong", "The stored cards could not be read.")
return
}
job := s.queue.Status(login)
if meta == nil && job.State == stateNone {
form := defaultForm()
form.User = login
s.render(w, http.StatusNotFound, "index", pageData{
Title: "ghglance",
Message: fmt.Sprintf("No cards for %s yet. Generate them below.", login),
Form: form,
})
return
}
d := pageData{
Title: login + " · ghglance",
Login: login,
Key: userKey(login),
Meta: meta,
Job: job,
Themes: theme.IDs(),
Theme: defaultTheme,
Form: defaultForm(),
}
d.Form.User = login
if t := r.URL.Query().Get("theme"); validTheme(t) {
d.Theme = t
}
switch r.URL.Query().Get("notice") {
case "pending":
if job.Active() {
d.Notice = "A generation for this user is already in progress."
}
case "granted-private":
d.Notice = "GitHub did not grant access to private repositories, so this generation counts public data only."
case "granted-org":
d.Notice = "GitHub did not grant read:org, so this generation leaves out org repos."
}
if meta != nil {
d.Login = meta.Login
d.Form = formFromOptions(meta.Login, meta.Options)
if left := s.store.cooldownLeft(login, s.cfg.Cooldown, time.Now()); left > 0 {
d.CooldownLeft = humanDuration(left)
}
if s.cfg.Retention > 0 {
d.ExpiresIn = humanDuration(max(meta.GeneratedAt.Add(s.cfg.Retention).Sub(time.Now()), time.Minute))
}
for _, f := range card.Filenames() {
src, err := s.cardDataURI(login, d.Theme, f)
if err != nil {
// A set expiring or being replaced between the meta read
// and this one is not worth logging; it just drops a card.
if !isNotExist(err) {
log.Printf("read card %s/%s/%s: %v", userKey(login), d.Theme, f, err)
}
continue
}
name := strings.ReplaceAll(strings.TrimSuffix(f, ".svg"), "-", " ")
d.Cards = append(d.Cards, cardView{Name: name, Src: src})
}
}
w.Header().Set("Cache-Control", "no-store")
s.render(w, http.StatusOK, "user", d)
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
login := r.PathValue("user")
if !validUsername(login) {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(s.queue.Status(login))
}
// cardDataURI reads one stored card and returns it as a base64 data: URI.
// The bytes are our own renderer's output and base64 cannot break out of
// the attribute, so the URI is marked safe for html/template, which would
// otherwise replace any data: URL.
func (s *Server) cardDataURI(login, themeID, file string) (template.URL, error) {
f, err := s.store.OpenCard(login, themeID, file)
if err != nil {
return "", err
}
defer f.Close()
st, err := f.Stat()
if err != nil {
return "", err
}
if !st.Mode().IsRegular() {
return "", fs.ErrNotExist
}
raw, err := io.ReadAll(io.LimitReader(f, maxCardBytes+1))
if err != nil {
return "", err
}
if len(raw) > maxCardBytes {
return "", fmt.Errorf("card larger than %d bytes", maxCardBytes)
}
return template.URL("data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString(raw)), nil
}
func (s *Server) render(w http.ResponseWriter, status int, page string, d pageData) {
var buf strings.Builder
if err := s.pages[page].ExecuteTemplate(&buf, "layout", d); err != nil {
log.Printf("render %s: %v", page, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
fmt.Fprint(w, buf.String())
}
func (s *Server) renderMessage(w http.ResponseWriter, status int, heading, message string) {
s.render(w, status, "message", pageData{Title: heading + " · ghglance", Heading: heading, Message: message})
}
func parsePages() (map[string]*template.Template, error) {
funcs := template.FuncMap{
"fmtTime": func(t time.Time) string { return t.UTC().Format("2006-01-02 15:04 UTC") },
}
pages := map[string]*template.Template{}
for _, name := range []string{"index", "user", "message"} {
t, err := template.New(name).Funcs(funcs).ParseFS(assets,
"templates/layout.html", "templates/form.html", "templates/"+name+".html")
if err != nil {
return nil, fmt.Errorf("parse %s template: %w", name, err)
}
pages[name] = t
}
return pages, nil
}
// formFromOptions pre-fills the regenerate form with a set's last options.
// Private and org scope are ticked only when the last generation used them,
// so a regenerate asks GitHub for no more than the set already shows.
func formFromOptions(login string, o Options) formValues {
return formValues{
User: login,
TZ: o.TZ,
StartOfWeek: o.StartOfWeek,
IncludeForks: o.IncludeForks,
IncludeOrgRepos: o.IncludeOrgRepos,
IncludePrivate: o.IncludePrivate,
CommitsPerRepo: strconv.Itoa(o.CommitsPerRepo),
}
}
func humanDuration(d time.Duration) string {
d = d.Round(time.Minute)
h, m := int(d.Hours()), int(d.Minutes())%60
switch {
case h > 0 && m > 0:
return fmt.Sprintf("%dh %dm", h, m)
case h > 0:
return fmt.Sprintf("%dh", h)
case m > 0:
return fmt.Sprintf("%dm", m)
}
return "under a minute"
}
func capitalize(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
// expireInterval is how often expired cards are swept while serving.
const expireInterval = time.Hour
// expireLoop deletes cards older than the retention once at startup and then
// every expireInterval until ctx is cancelled.
func (s *Server) expireLoop(ctx context.Context) {
if s.cfg.Retention <= 0 {
return
}
t := time.NewTicker(expireInterval)
defer t.Stop()
for {
if n := s.store.Expire(s.cfg.Retention, time.Now()); n > 0 {
log.Printf("expired cards for %d user(s) older than %s", n, s.cfg.Retention)
}
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}