Files
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

184 lines
5.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"github.com/tiennm99/ghglance/internal/card"
"github.com/tiennm99/ghglance/internal/github"
"github.com/tiennm99/ghglance/internal/theme"
)
// GitHub logins: alphanumerics separated by single hyphens, 1–39 chars, no
// leading or trailing hyphen. Anything passing this is also a safe single
// path segment, which is what lets it name a directory under the data dir.
var usernameRE = regexp.MustCompile(`^[A-Za-z0-9]+(-[A-Za-z0-9]+)*$`)
// Tokens, whether GitHub's token endpoint returned them or a visitor pasted
// one, are only ever forwarded in an Authorization header; restricting the
// charset rules out header injection.
var tokenRE = regexp.MustCompile(`^[A-Za-z0-9_]{20,255}$`)
// IANA zone names: letters, digits and _ + - / only. time.LoadLocation
// refuses ".." itself; the charset keeps odd input out of the zoneinfo
// lookup entirely.
var tzRE = regexp.MustCompile(`^[A-Za-z0-9_+\-/]{1,64}$`)
const (
defaultTheme = "dracula"
defaultCommitsPerRepo = 500
maxCommitsPerRepo = 5000
)
// cardFiles is the set of servable card basenames, built from the renderer.
var cardFiles = func() map[string]bool {
m := map[string]bool{}
for _, f := range card.Filenames() {
m[f] = true
}
return m
}()
// validUsername reports whether s is a syntactically valid GitHub login.
func validUsername(s string) bool {
return len(s) >= 1 && len(s) <= 39 && usernameRE.MatchString(s)
}
// validTheme reports whether id names a registered theme.
func validTheme(id string) bool {
_, ok := theme.Lookup(id)
return ok
}
// validCard reports whether name is a rendered card basename ("stats.svg").
func validCard(name string) bool {
return cardFiles[name]
}
// Options are the generation settings recorded in meta.json. They never
// include a token.
type Options struct {
TZ string `json:"tz"`
StartOfWeek string `json:"start_of_week"`
IncludeForks bool `json:"include_forks"`
IncludeOrgRepos bool `json:"include_org_repos"`
IncludePrivate bool `json:"include_private"`
CommitsPerRepo int `json:"commits_per_repo"`
}
// submission is a validated form post. Token is either the token the
// visitor pasted into the form (Pasted is true) or the sign-in token GitHub
// issues at the callback. A sign-in token is revoked when its job ends; a
// pasted one belongs to the visitor and is only dropped.
type submission struct {
Login string
Token string
Pasted bool
Options Options
}
// formValues is the raw form, kept so a rejected post re-renders as typed.
// It never carries a pasted token back to the page.
type formValues struct {
User string
TZ string
StartOfWeek string
IncludeForks bool
IncludeOrgRepos bool
IncludePrivate bool
CommitsPerRepo string
}
// defaultForm is the blank generation form. Private repos start unticked: a
// sign-in asks for exactly what is ticked, and repo is a broad grant that
// only helps when the username is the visitor's own account.
func defaultForm() formValues {
return formValues{
TZ: "UTC",
StartOfWeek: "sunday",
IncludeForks: true,
CommitsPerRepo: strconv.Itoa(defaultCommitsPerRepo),
}
}
// parseSubmission validates a generation form. The ticked scope is kept:
// it picks the scopes a sign-in asks for, and the job still enforces who
// the token, signed in or pasted, belongs to. A non-empty pasted token is
// returned with Pasted set.
func parseSubmission(get func(string) string) (submission, formValues, error) {
f := formValues{
User: strings.TrimSpace(get("user")),
TZ: strings.TrimSpace(get("tz")),
StartOfWeek: strings.TrimSpace(get("start_of_week")),
IncludeForks: get("include_forks") != "",
IncludeOrgRepos: get("include_org_repos") != "",
IncludePrivate: get("include_private") != "",
CommitsPerRepo: strings.TrimSpace(get("commits_per_repo")),
}
token := strings.TrimSpace(get("token"))
if !validUsername(f.User) {
return submission{}, f, errors.New("enter a valid GitHub username: letters, digits and single hyphens, up to 39 characters")
}
if token != "" && !tokenRE.MatchString(token) {
return submission{}, f, errors.New("that does not look like a GitHub token")
}
tz := f.TZ
if tz == "" {
tz = "UTC"
}
if tz == "Local" || !tzRE.MatchString(tz) {
return submission{}, f, fmt.Errorf("unknown timezone %q", tz)
}
if _, err := time.LoadLocation(tz); err != nil {
return submission{}, f, fmt.Errorf("unknown timezone %q", tz)
}
wd, err := github.ParseWeekday(f.StartOfWeek)
if err != nil {
return submission{}, f, errors.New("unknown start of week")
}
perRepo := defaultCommitsPerRepo
if f.CommitsPerRepo != "" {
n, err := strconv.Atoi(f.CommitsPerRepo)
if err != nil || n < 0 || n > maxCommitsPerRepo {
return submission{}, f, fmt.Errorf("commits per repo must be between 0 and %d", maxCommitsPerRepo)
}
perRepo = n
}
return submission{Login: f.User, Token: token, Pasted: token != "", Options: Options{
TZ: tz,
StartOfWeek: strings.ToLower(wd.String()),
IncludeForks: f.IncludeForks,
IncludeOrgRepos: f.IncludeOrgRepos,
IncludePrivate: f.IncludePrivate,
CommitsPerRepo: perRepo,
}}, f, nil
}
// collectConfig maps validated options onto the shared fetch pipeline.
func (o Options) collectConfig() github.CollectConfig {
loc, err := time.LoadLocation(o.TZ)
if err != nil {
loc = time.UTC
}
wd, _ := github.ParseWeekday(o.StartOfWeek)
return github.CollectConfig{
Options: github.FetchOptions{
IncludeForks: o.IncludeForks,
IncludePrivate: o.IncludePrivate,
IncludeOrgRepos: o.IncludeOrgRepos,
},
Location: loc,
WeekStart: wd,
CommitsPerRepo: o.CommitsPerRepo,
}
}