Files
ghglance/internal/web/validate.go
T
tiennm99 2def27f49a feat(web): require GitHub sign-in to generate cards
The web UI now runs every job on an OAuth token from "Sign in with
GitHub". The ticked options decide the requested scopes (read:user, plus
repo for private repos, plus read:org for org repos); a grant wider than
requested is refused. The token lives only on the job and is revoked when
the job ends, on every path including shutdown.

The server no longer holds a GitHub token of its own, and the pasted-token
field and /generate are gone. -serve requires -oauth-client-id,
-oauth-client-secret and -public-url (GHGLANCE_OAUTH_* and
GHGLANCE_PUBLIC_URL), and compose.yml requires them too. The CLI and the
Action keep -token unchanged.
2026-10-07 17:50:33 +07:00

175 lines
5.2 KiB
Go
Raw 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]+)*$`)
// Sign-in tokens are only ever forwarded in an Authorization header;
// restricting the charset of what GitHub's token endpoint returns 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 the sign-in 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 the sign-in token GitHub
// issues at the callback; it is revoked when the job ends.
type submission struct {
Login string
Token string
Options Options
}
// formValues is the raw form, kept so a rejected post re-renders as typed.
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 the sign-in asks for, and the job still enforces who
// the resulting token belongs to.
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")),
}
if !validUsername(f.User) {
return submission{}, f, errors.New("enter a valid GitHub username: letters, digits and single hyphens, up to 39 characters")
}
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, 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,
}
}