mirror of
https://github.com/tiennm99/ghglance.git
synced 2026-10-11 12:18:55 +00:00
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.
201 lines
6.9 KiB
Go
201 lines
6.9 KiB
Go
// ghglance generates SVG cards summarizing a GitHub user's profile.
|
|
package main
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/tiennm99/ghglance/internal/card"
|
|
"github.com/tiennm99/ghglance/internal/github"
|
|
"github.com/tiennm99/ghglance/internal/theme"
|
|
"github.com/tiennm99/ghglance/internal/web"
|
|
)
|
|
|
|
func main() {
|
|
var (
|
|
user = flag.String("user", "", "GitHub username (required)")
|
|
token = flag.String("token", os.Getenv("GITHUB_TOKEN"), "GitHub token (or env GITHUB_TOKEN); not used by -serve, which runs every generation on the visitor's sign-in")
|
|
out = flag.String("out", "output", "output directory")
|
|
themesFlag = flag.String("themes", "dracula", "comma-separated theme ids, or 'all'")
|
|
tzName = flag.String("tz", "Local", "timezone for productive-time card (IANA name, e.g. Asia/Saigon)")
|
|
topRepos = flag.Int("top-repos", 0, "optional cap on seed repos probed for commit history (0 = unlimited)")
|
|
perRepo = flag.Int("commits-per-repo", 500, "max commits sampled per repo, 0 = every commit (covers both last-year and all-time aggregates)")
|
|
includeForks = flag.Bool("include-forks", true, "include forked repos in stats and commit probing")
|
|
includePrivate = flag.Bool("include-private", true, "include private repos (requires PAT with repo scope; silently no-op otherwise)")
|
|
includeOrgs = flag.Bool("include-org-repos", false, "count org-owned repos you administer toward stars, repo count, repos-per-language and top-starred")
|
|
timeout = flag.Duration("timeout", 30*time.Minute, "overall deadline for fetch phase; per generation job under -serve (0 = no limit)")
|
|
startOfWeek = flag.String("start-of-week", "sunday", "first day of week for heatmap rows and weekday bars (sunday|monday|tuesday|…)")
|
|
listThemes = flag.Bool("list-themes", false, "print available theme ids and exit")
|
|
serve = flag.String("serve", "", "run the web UI on this address (e.g. :8080) instead of generating once")
|
|
dataDir = flag.String("data-dir", "data", "web UI: directory holding generated cards")
|
|
cooldown = flag.Duration("cooldown", 6*time.Hour, "web UI: minimum age of a user's cards before someone signed in as another account can regenerate them")
|
|
retention = flag.Duration("retention", 24*time.Hour, "web UI: delete a user's generated cards this long after they were generated (0 = keep forever)")
|
|
workers = flag.Int("workers", 2, "web UI: concurrent generation jobs")
|
|
// Empty defaults keep the secret out of -help; the env fallback is
|
|
// applied after parsing.
|
|
oauthID = flag.String("oauth-client-id", "", "web UI, required: GitHub OAuth App client ID for \"Sign in with GitHub\" (or env GHGLANCE_OAUTH_CLIENT_ID)")
|
|
oauthSecret = flag.String("oauth-client-secret", "", "web UI, required: GitHub OAuth App client secret (or env GHGLANCE_OAUTH_CLIENT_SECRET)")
|
|
publicURL = flag.String("public-url", "", "web UI, required: external origin, e.g. https://ghglance.example.com; the OAuth callback is <public-url>/auth/callback (or env GHGLANCE_PUBLIC_URL)")
|
|
)
|
|
flag.Parse()
|
|
|
|
if *listThemes {
|
|
for _, id := range theme.IDs() {
|
|
fmt.Println(id)
|
|
}
|
|
return
|
|
}
|
|
|
|
if *serve != "" {
|
|
oauth := web.OAuthConfig{
|
|
ClientID: cmp.Or(*oauthID, os.Getenv("GHGLANCE_OAUTH_CLIENT_ID")),
|
|
ClientSecret: cmp.Or(*oauthSecret, os.Getenv("GHGLANCE_OAUTH_CLIENT_SECRET")),
|
|
PublicURL: cmp.Or(*publicURL, os.Getenv("GHGLANCE_PUBLIC_URL")),
|
|
}
|
|
if missing := missingOAuthSettings(oauth); len(missing) > 0 {
|
|
fmt.Fprintf(os.Stderr, "error: -serve requires sign in with GitHub; set %s\n", strings.Join(missing, ", "))
|
|
os.Exit(2)
|
|
}
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
err := web.Run(ctx, web.Config{
|
|
Addr: *serve,
|
|
DataDir: *dataDir,
|
|
Cooldown: *cooldown,
|
|
Retention: *retention,
|
|
Workers: *workers,
|
|
JobTimeout: *timeout,
|
|
OAuth: oauth,
|
|
})
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
}
|
|
|
|
if *user == "" {
|
|
fmt.Fprintln(os.Stderr, "error: -user is required")
|
|
flag.Usage()
|
|
os.Exit(2)
|
|
}
|
|
|
|
selected, err := resolveThemes(*themesFlag)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(2)
|
|
}
|
|
|
|
loc, err := time.LoadLocation(*tzName)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "warn: unknown timezone %q, falling back to UTC\n", *tzName)
|
|
loc = time.UTC
|
|
}
|
|
|
|
weekStart, err := github.ParseWeekday(*startOfWeek)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "warn: %v, falling back to Sunday\n", err)
|
|
weekStart = time.Sunday
|
|
}
|
|
|
|
opts := github.FetchOptions{
|
|
IncludeForks: *includeForks,
|
|
IncludePrivate: *includePrivate,
|
|
IncludeOrgRepos: *includeOrgs,
|
|
}
|
|
|
|
// Overall fetch budget. Ctrl-C cancels in-flight HTTP requests cleanly.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
if *timeout > 0 {
|
|
var cancelTimeout context.CancelFunc
|
|
ctx, cancelTimeout = context.WithTimeout(ctx, *timeout)
|
|
defer cancelTimeout()
|
|
}
|
|
sig := make(chan os.Signal, 1)
|
|
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
|
go func() {
|
|
<-sig
|
|
cancel()
|
|
}()
|
|
|
|
profile, err := github.Collect(ctx, github.NewClient(*token), *user, github.CollectConfig{
|
|
Options: opts,
|
|
Location: loc,
|
|
WeekStart: weekStart,
|
|
TopRepos: *topRepos,
|
|
CommitsPerRepo: *perRepo,
|
|
Warnf: func(format string, args ...any) {
|
|
fmt.Fprintf(os.Stderr, "warn: "+format+"\n", args...)
|
|
},
|
|
})
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
for _, t := range selected {
|
|
if err := card.RenderAll(profile, t, *out); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: render %s: %v\n", t.ID, err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("wrote %s/%s/\n", *out, t.ID)
|
|
}
|
|
}
|
|
|
|
// missingOAuthSettings names each empty sign-in setting as its flag and
|
|
// environment variable.
|
|
func missingOAuthSettings(c web.OAuthConfig) []string {
|
|
var missing []string
|
|
if c.ClientID == "" {
|
|
missing = append(missing, "-oauth-client-id (GHGLANCE_OAUTH_CLIENT_ID)")
|
|
}
|
|
if c.ClientSecret == "" {
|
|
missing = append(missing, "-oauth-client-secret (GHGLANCE_OAUTH_CLIENT_SECRET)")
|
|
}
|
|
if c.PublicURL == "" {
|
|
missing = append(missing, "-public-url (GHGLANCE_PUBLIC_URL)")
|
|
}
|
|
return missing
|
|
}
|
|
|
|
func resolveThemes(spec string) ([]theme.Theme, error) {
|
|
spec = strings.TrimSpace(spec)
|
|
if spec == "" {
|
|
return nil, fmt.Errorf("no themes specified")
|
|
}
|
|
if spec == "all" {
|
|
ids := theme.IDs()
|
|
out := make([]theme.Theme, 0, len(ids))
|
|
for _, id := range ids {
|
|
if t, ok := theme.Lookup(id); ok {
|
|
out = append(out, t)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
var out []theme.Theme
|
|
for _, id := range strings.Split(spec, ",") {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
continue
|
|
}
|
|
t, ok := theme.Lookup(id)
|
|
if !ok {
|
|
return nil, fmt.Errorf("unknown theme %q (use -list-themes)", id)
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, fmt.Errorf("no valid themes")
|
|
}
|
|
return out, nil
|
|
}
|