~gitpocket/gitpocket

4

feat(search): detect languages with go-enry and gitattributes overrides

commit
9f0973107111f906b1e6b98558e8be2406bddcba browse tree
parent
4017ccaf45bdbd751e5aeaa391d7510005b657f5
author
obscurejames <354576047+obscurejames@users.noreply.github.com>
signature
Signed by: ~james SHA256:KlzJ0P0gvJcnOYpaxD5yCUw52ImHttTKRjVAEnnYnKg
date
2026-11-13 15:31 UTC

111 files changed, +540 −342

.gitattributes added

@@ -0,0 +1,3 @@
1+*.tmpl linguist-detectable=false
2+*.css linguist-detectable
3+web/static/katex/** linguist-vendored

account.go → forge/account.go renamed

@@ -1,4 +1,4 @@
1-package main
1+package forge
22
33 import (
44 "encoding/base64"

admin.go → forge/admin.go renamed

@@ -1,4 +1,4 @@
1-package main
1+package forge
22
33 import (
44 "errors"

attach.go → forge/attach.go renamed

@@ -1,4 +1,4 @@
1-package main
1+package forge
22
33 import (
44 "bytes"

avatar.go → forge/avatar.go renamed

@@ -1,4 +1,4 @@
1-package main
1+package forge
22
33 import (
44 "bytes"

backup.go → forge/backup.go renamed

@@ -1,4 +1,4 @@
1-package main
1+package forge
22
33 import (
44 "context"

forge/cli.go added

@@ -0,0 +1,251 @@
1+package forge
2+
3+import (
4+ "bufio"
5+ "flag"
6+ "fmt"
7+ "log"
8+ "net"
9+ "net/http"
10+ "os"
11+ "path/filepath"
12+ "strconv"
13+ "strings"
14+ "time"
15+)
16+
17+func Main() {
18+ root := flag.String("root", "/srv/gitpocket", "data directory holding gitpocket.db and repos/")
19+ flag.Usage = func() {
20+ fmt.Fprintln(os.Stderr, `usage: gitpocket [-root dir] command
21+
22+ serve [-listen addr] [-host name] [-name text] [-ssh-user name] [-proxy cidr,...] run the web server
23+ keys user home type key sshd AuthorizedKeysCommand
24+ shell user forced command for ssh git access
25+ invite print a single use registration code
26+ registration [open|invite|closed] show or set who may register
27+ admin name | unadmin name grant or revoke site administration
28+ quota [size] show or set the per user disk quota, 0 disables
29+ user name create a user, password read from stdin
30+ passwd name set a user's password, read from stdin
31+ ban name | unban name block or restore an account
32+ delete name remove an account and its repositories
33+ feature owner/repo... | -owner/repo set or clear the featured projects, in order
34+ org name [+user|-user]... create an organization and add or remove members
35+ mail address send a test message through the configured mailer
36+ backup dir | file.db snapshot the database and bundle every changed repository into dir`)
37+ }
38+ flag.Parse()
39+ args := flag.Args()
40+ if len(args) == 0 {
41+ flag.Usage()
42+ os.Exit(2)
43+ }
44+ switch args[0] {
45+ case "serve":
46+ serve(*root, args[1:])
47+ case "keys":
48+ binary, _ := os.Executable()
49+ os.Exit(keys(*root, binary, args[1:]))
50+ case "hook":
51+ if len(args) != 3 {
52+ os.Exit(2)
53+ }
54+ os.Exit(hook(*root, args[1], args[2]))
55+ case "shell":
56+ if len(args) != 2 {
57+ os.Exit(2)
58+ }
59+ os.Exit(shell(*root, args[1]))
60+ case "quota":
61+ if len(args) == 2 {
62+ n, err := parseSize(args[1])
63+ if err != nil {
64+ log.Fatal(err)
65+ }
66+ if err := writeQuota(*root, n); err != nil {
67+ log.Fatal(err)
68+ }
69+ }
70+ if q := readQuota(*root); q == 0 {
71+ fmt.Println("no quota")
72+ } else {
73+ fmt.Println(humanSize(q))
74+ }
75+ case "registration":
76+ store := mustStore(*root)
77+ if len(args) == 2 {
78+ if !validRegistration(args[1]) {
79+ log.Fatal("registration is open, invite or closed")
80+ }
81+ if err := store.SetSetting("registration", args[1]); err != nil {
82+ log.Fatal(err)
83+ }
84+ }
85+ fmt.Println(store.Registration())
86+ case "admin", "unadmin":
87+ if len(args) != 2 {
88+ os.Exit(2)
89+ }
90+ if err := mustStore(*root).SetAdmin(args[1], args[0] == "admin"); err != nil {
91+ log.Fatal("no such user")
92+ }
93+ case "invite":
94+ store := mustStore(*root)
95+ code, err := store.CreateInvite()
96+ if err != nil {
97+ log.Fatal(err)
98+ }
99+ fmt.Println(code)
100+ case "user":
101+ if len(args) != 2 {
102+ os.Exit(2)
103+ }
104+ store := mustStore(*root)
105+ password := readLine()
106+ if _, err := store.CreateUser(args[1], password); err != nil {
107+ log.Fatal(err)
108+ }
109+ case "passwd":
110+ if len(args) != 2 {
111+ os.Exit(2)
112+ }
113+ store := mustStore(*root)
114+ u, err := store.UserByName(args[1])
115+ if err != nil {
116+ log.Fatal("no such user")
117+ }
118+ password := readLine()
119+ if err := store.SetPassword(u.ID, password); err != nil {
120+ log.Fatal(err)
121+ }
122+ store.DeleteSessions(u.ID)
123+ case "backup":
124+ if len(args) != 2 {
125+ os.Exit(2)
126+ }
127+ if err := backup(*root, args[1], 14); err != nil {
128+ log.Fatal(err)
129+ }
130+ case "mail":
131+ if len(args) != 2 {
132+ os.Exit(2)
133+ }
134+ if err := loadMailer(*root).Send(args[1], "test message from gitpocket", "Mail delivery from this forge works.\n"); err != nil {
135+ log.Fatal(err)
136+ }
137+ fmt.Println("sent")
138+ case "org":
139+ if len(args) < 2 {
140+ os.Exit(2)
141+ }
142+ store := mustStore(*root)
143+ org, err := store.UserByName(args[1])
144+ if err != nil {
145+ if org, err = store.CreateOrg(args[1]); err != nil {
146+ log.Fatal(err)
147+ }
148+ } else if !store.IsOrg(org.ID) {
149+ log.Fatalf("~%s is a user, not an organization", args[1])
150+ }
151+ for _, spec := range args[2:] {
152+ name := strings.TrimPrefix(strings.TrimLeft(spec, "+-"), "~")
153+ u, err := store.UserByName(name)
154+ if err != nil || store.IsOrg(u.ID) {
155+ log.Fatalf("%s: no such user", spec)
156+ }
157+ if err := store.SetMember(org.ID, u.ID, !strings.HasPrefix(spec, "-")); err != nil {
158+ log.Fatal(err)
159+ }
160+ }
161+ case "feature":
162+ if len(args) < 2 {
163+ os.Exit(2)
164+ }
165+ store := mustStore(*root)
166+ for i, spec := range args[1:] {
167+ owner, name, ok := strings.Cut(strings.TrimPrefix(spec, "~"), "/")
168+ rank := i + 1
169+ if strings.HasPrefix(spec, "-") {
170+ owner, name, ok = strings.Cut(strings.TrimPrefix(spec, "-~"), "/")
171+ rank = 0
172+ }
173+ if !ok {
174+ log.Fatalf("%s: expected owner/repo", spec)
175+ }
176+ if err := store.SetFeatured(owner, name, rank); err != nil {
177+ log.Fatalf("%s: no such repository", spec)
178+ }
179+ }
180+ case "ban", "unban":
181+ if len(args) != 2 {
182+ os.Exit(2)
183+ }
184+ if err := mustStore(*root).SetBanned(args[1], args[0] == "ban"); err != nil {
185+ log.Fatal(err)
186+ }
187+ case "delete":
188+ if len(args) != 2 {
189+ os.Exit(2)
190+ }
191+ store := mustStore(*root)
192+ u, err := store.UserByName(args[1])
193+ if err != nil {
194+ log.Fatal("no such user")
195+ }
196+ if err := store.DeleteUser(args[1]); err != nil {
197+ log.Fatal(err)
198+ }
199+ os.RemoveAll(filepath.Join(*root, "repos", args[1]))
200+ os.Remove(filepath.Join(*root, "avatars", strconv.FormatInt(u.ID, 10)))
201+ default:
202+ flag.Usage()
203+ os.Exit(2)
204+ }
205+}
206+
207+func mustStore(root string) *Store {
208+ if err := os.MkdirAll(filepath.Join(root, "repos"), 0o755); err != nil {
209+ log.Fatal(err)
210+ }
211+ store, err := openStore(filepath.Join(root, "gitpocket.db"), false)
212+ if err != nil {
213+ log.Fatal(err)
214+ }
215+ return store
216+}
217+
218+func serve(root string, args []string) {
219+ fs := flag.NewFlagSet("serve", flag.ExitOnError)
220+ listen := fs.String("listen", "127.0.0.1:3200", "address to listen on")
221+ host := fs.String("host", "localhost", "public hostname used in clone urls")
222+ name := fs.String("name", "gitpocket", "site name shown in the header")
223+ sshUser := fs.String("ssh-user", "git", "unix user that serves git over ssh, shown in clone urls")
224+ proxies := fs.String("proxy", "", "comma separated networks besides loopback whose X-Forwarded-For is trusted")
225+ fs.Parse(args)
226+ for _, cidr := range strings.Split(*proxies, ",") {
227+ if cidr = strings.TrimSpace(cidr); cidr == "" {
228+ continue
229+ }
230+ _, n, err := net.ParseCIDR(cidr)
231+ if err != nil {
232+ log.Fatalf("-proxy %s: %v", cidr, err)
233+ }
234+ trustedProxies = append(trustedProxies, n)
235+ }
236+ store := mustStore(root)
237+ srv := &http.Server{
238+ Addr: *listen,
239+ Handler: newServer(store, root, *host, *name, *sshUser).handler(),
240+ ReadHeaderTimeout: 10 * time.Second,
241+ IdleTimeout: 2 * time.Minute,
242+ MaxHeaderBytes: 64 << 10,
243+ }
244+ log.Printf("gitpocket listening on %s for %s", *listen, *host)
245+ log.Fatal(srv.ListenAndServe())
246+}
247+
248+func readLine() string {
249+ line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
250+ return strings.TrimRight(line, "\r\n")
251+}

edit.go → forge/edit.go renamed

@@ -1,4 +1,4 @@
1-package main
1+package forge
22
33 import (
44 "bufio"

extra.go → forge/extra.go renamed

@@ -1,4 +1,4 @@
1-package main
1+package forge
22
33 import (
44 "compress/gzip"

flow_test.go → forge/flow_test.go renamed

@@ -1,4 +1,4 @@
1-package main
1+package forge
22
33 import (
44 "bytes"

git.go → forge/git.go renamed

@@ -1,4 +1,4 @@
1-package main
1+package forge
22
33 import (
44 "bytes"

highlight.go → forge/highlight.go renamed

@@ -1,4 +1,4 @@
1-package main
1+package forge
22
33 import (
44 "bytes"

inbox.go → forge/inbox.go renamed

@@ -1,4 +1,4 @@
1-package main
1+package forge
22
33 import (
44 "fmt"

issues.go → forge/issues.go renamed

@@ -1,4 +1,4 @@
1-package main
1+package forge
22
33 import (
44 "database/sql"

labels.go → forge/labels.go renamed

@@ -1,4 +1,4 @@
1-package main
1+package forge
22
33 import (
44 "errors"

limit.go → forge/limit.go renamed

@@ -1,4 +1,4 @@
1-package main
1+package forge
22
33 import (
44 "net"

mail.go → forge/mail.go renamed

@@ -1,4 +1,4 @@
1-package main
1+package forge
22
33 import (
44 "bytes"

mail_test.go → forge/mail_test.go renamed

@@ -1,4 +1,4 @@
1-package main
1+package forge
22
33 import (
44 "encoding/json"

pull.go → forge/pull.go renamed

@@ -1,4 +1,4 @@
1-package main
1+package forge
22
33 import (
44 "database/sql"

quota.go → forge/quota.go renamed

@@ -1,4 +1,4 @@
1-package main
1+package forge
22
33 import (
44 "errors"

search.go → forge/search.go renamed

@@ -1,6 +1,7 @@
1-package main
1+package forge
22
33 import (
4+ "bytes"
45 "net/http"
56 "path"
67 "slices"
@@ -8,6 +9,8 @@ import (
89 "strings"
910 "sync"
1011 "time"
12+
13+ "github.com/go-enry/go-enry/v2"
1114 )
1215
1316 type language struct {
@@ -29,7 +32,7 @@ var languages = map[string][2]string{
2932 ".ex": {"Elixir", "#6e4a7e"}, ".exs": {"Elixir", "#6e4a7e"}, ".erl": {"Erlang", "#b83998"}, ".gleam": {"Gleam", "#ffaff3"},
3033 ".lua": {"Lua", "#000080"}, ".nix": {"Nix", "#7e7eff"}, ".sh": {"Shell", "#89e051"}, ".tex": {"TeX", "#3d6117"},
3134 ".html": {"HTML", "#e34c26"}, ".css": {"CSS", "#663399"}, ".scm": {"Scheme", "#1e4aec"}, ".rkt": {"Racket", "#3c5caa"},
32- ".lisp": {"Common Lisp", "#3fb68b"}, ".clj": {"Clojure", "#db5855"}, ".fs": {"F#", "#b845fc"}, ".nim": {"Nim", "#ffc200"}, ".mm": {"Objective-C++", "#6866fb"},
35+ ".lisp": {"Common Lisp", "#3fb68b"}, ".clj": {"Clojure", "#db5855"}, ".fs": {"F#", "#b845fc"}, ".nim": {"Nim", "#ffc200"}, ".tmpl": {"Go Template", "#5a9fb5"}, ".gotmpl": {"Go Template", "#5a9fb5"}, ".gohtml": {"Go Template", "#5a9fb5"}, ".mm": {"Objective-C++", "#6866fb"},
3336 ".fst": {"F*", "#572e30"}, ".fsti": {"F*", "#572e30"}, ".dfy": {"Dafny", "#ffec25"}, ".tla": {"TLA+", "#4b0079"}, ".als": {"Alloy", "#64c800"},
3437 ".mlw": {"WhyML", "#9e3b3b"}, ".bpl": {"Boogie", "#c80fa0"}, ".vpr": {"Viper", "#7d5c99"}, ".pvs": {"PVS", "#7a5a3b"}, ".pml": {"Promela", "#de0000"},
3538 ".smv": {"NuSMV", "#5c6bc0"}, ".sy": {"SyGuS", "#7b8d9a"}, ".sl": {"SyGuS", "#7b8d9a"}, ".smt": {"SMT", "#7b8d9a"}, ".z3": {"SMT", "#7b8d9a"},
@@ -62,21 +65,6 @@ var languages = map[string][2]string{
6265 ".sig": {"Standard ML", "#dc566d"}, ".fun": {"Standard ML", "#dc566d"}, ".fut": {"Futhark", "#5f021f"}, ".u": {"Unison", "#2f4f4f"},
6366 }
6467
65-var vendorDirs = []string{"vendor/", "node_modules/", "third_party/", "thirdparty/", "external/", "deps/", "dist/", "katex/"}
66-
67-func vendored(name string) bool {
68- base := path.Base(name)
69- if strings.Contains(base, ".min.") || strings.HasSuffix(base, ".pb.go") || strings.HasSuffix(base, "_generated.go") {
70- return true
71- }
72- for _, d := range vendorDirs {
73- if strings.HasPrefix(name, d) || strings.Contains(name, "/"+d) {
74- return true
75- }
76- }
77- return false
78-}
79-
8068 type langEntry struct {
8169 head string
8270 stamp time.Time
@@ -108,42 +96,7 @@ func (s *Server) repoLanguages(repo Repo) []language {
10896 langCache.Unlock()
10997 return e.langs
11098 }
111- out, err := g.run(nil, "ls-tree", "-r", "-l", "-z", head)
112- if err != nil {
113- return nil
114- }
115- bytes := map[string]int64{}
116- var total int64
117- for _, rec := range strings.Split(string(out), "\x00") {
118- meta, name, ok := strings.Cut(rec, "\t")
119- f := strings.Fields(meta)
120- if !ok || len(f) != 4 || f[1] != "blob" || vendored(name) {
121- continue
122- }
123- l, known := languages[path.Ext(name)]
124- if !known {
125- l, known = languages[strings.ToLower(path.Ext(name))]
126- }
127- if !known {
128- continue
129- }
130- size, _ := strconv.ParseInt(f[3], 10, 64)
131- bytes[l[0]] += size
132- total += size
133- }
134- var langs []language
135- for _, l := range languages {
136- if n, ok := bytes[l[0]]; ok {
137- langs = append(langs, language{Name: l[0], Colour: l[1], Bytes: n, Percent: float64(n) * 100 / float64(total)})
138- delete(bytes, l[0])
139- }
140- }
141- slices.SortFunc(langs, func(a, b language) int {
142- if a.Bytes != b.Bytes {
143- return int(b.Bytes - a.Bytes)
144- }
145- return strings.Compare(a.Name, b.Name)
146- })
99+ langs := g.languages(head)
147100 langCache.Lock()
148101 langCache.m[repo.ID] = langEntry{head, stamp, langs}
149102 langCache.Unlock()
@@ -409,3 +362,205 @@ func (s *Server) people() []userHit {
409362 peopleCache.hits, peopleCache.at = hits, time.Now()
410363 return hits
411364 }
365+
366+type blob struct {
367+ name, id string
368+ size int64
369+ lang string
370+ guesses []string
371+}
372+
373+func (g Git) languages(head string) []language {
374+ out, err := g.run(nil, "ls-tree", "-r", "-l", "-z", head)
375+ if err != nil {
376+ return nil
377+ }
378+ var blobs []blob
379+ attributes := false
380+ for _, rec := range strings.Split(string(out), "\x00") {
381+ meta, name, ok := strings.Cut(rec, "\t")
382+ f := strings.Fields(meta)
383+ if !ok || len(f) != 4 || f[1] != "blob" {
384+ continue
385+ }
386+ size, _ := strconv.ParseInt(f[3], 10, 64)
387+ if size > 0 {
388+ blobs = append(blobs, blob{name: name, id: f[2], size: size})
389+ }
390+ attributes = attributes || path.Base(name) == ".gitattributes"
391+ }
392+ var attrs map[string]map[string]string
393+ if attributes {
394+ attrs = g.linguist(head, blobs)
395+ }
396+ var queued []*blob
397+ var budget int64 = 8 << 20
398+ kept := blobs[:0]
399+ for _, b := range blobs {
400+ a := attrs[b.name]
401+ if skip(a["linguist-vendored"], enry.IsVendor(b.name)) || skip(a["linguist-generated"], enry.IsGenerated(b.name, nil)) ||
402+ skip(a["linguist-documentation"], enry.IsDocumentation(b.name)) ||
403+ a["linguist-detectable"] != "set" && (enry.IsDotFile(b.name) || enry.IsConfiguration(b.name)) {
404+ continue
405+ }
406+ if v := a["linguist-language"]; v != "" && v != "set" && v != "unset" {
407+ b.lang, _ = enry.GetLanguageByAlias(v)
408+ }
409+ if b.lang == "" {
410+ b.guesses = enry.GetLanguagesByFilename(b.name, nil, nil)
411+ if len(b.guesses) == 0 {
412+ b.guesses = enry.GetLanguagesByExtension(b.name, nil, nil)
413+ }
414+ }
415+ kept = append(kept, b)
416+ }
417+ for i := range kept {
418+ b := &kept[i]
419+ if len(b.guesses) > 1 && b.size <= 256<<10 && budget >= b.size {
420+ budget -= b.size
421+ queued = append(queued, b)
422+ }
423+ }
424+ if len(queued) > 0 {
425+ var ids strings.Builder
426+ for _, b := range queued {
427+ ids.WriteString(b.id + "\n")
428+ }
429+ out, _ := g.run([]byte(ids.String()), "cat-file", "--batch")
430+ for _, b := range queued {
431+ header, rest, ok := bytes.Cut(out, []byte("\n"))
432+ f := strings.Fields(string(header))
433+ if !ok || len(f) != 3 {
434+ break
435+ }
436+ n, _ := strconv.Atoi(f[2])
437+ if n > len(rest) {
438+ break
439+ }
440+ content := rest[:min(n, 16<<10)]
441+ out = rest[min(n+1, len(rest)):]
442+ if l := enry.GetLanguage(path.Base(b.name), content); l != "" {
443+ b.lang = l
444+ }
445+ }
446+ }
447+ votes := map[string]map[string]int{}
448+ for _, b := range kept {
449+ if len(b.guesses) > 1 && b.lang != "" {
450+ ext := strings.ToLower(path.Ext(b.name))
451+ if votes[ext] == nil {
452+ votes[ext] = map[string]int{}
453+ }
454+ votes[ext][b.lang]++
455+ }
456+ }
457+ for i := range kept {
458+ b := &kept[i]
459+ if len(b.guesses) <= 1 {
460+ continue
461+ }
462+ best, n := "", 0
463+ for lang, c := range votes[strings.ToLower(path.Ext(b.name))] {
464+ if c > n || c == n && lang < best {
465+ best, n = lang, c
466+ }
467+ }
468+ if best != "" {
469+ b.lang = best
470+ }
471+ }
472+ sizes := map[string]int64{}
473+ colours := map[string]string{}
474+ var total int64
475+ for _, b := range kept {
476+ lang, colour, ok := classify(b, attrs[b.name]["linguist-detectable"])
477+ if !ok {
478+ continue
479+ }
480+ sizes[lang] += b.size
481+ colours[lang] = colour
482+ total += b.size
483+ }
484+ var langs []language
485+ for name, n := range sizes {
486+ langs = append(langs, language{Name: name, Colour: colours[name], Bytes: n, Percent: float64(n) * 100 / float64(total)})
487+ }
488+ slices.SortFunc(langs, func(a, b language) int {
489+ if a.Bytes != b.Bytes {
490+ return int(b.Bytes - a.Bytes)
491+ }
492+ return strings.Compare(a.Name, b.Name)
493+ })
494+ return langs
495+}
496+
497+func skip(attr string, guess bool) bool {
498+ if attr == "set" || attr == "true" {
499+ return true
500+ }
501+ if attr == "unset" || attr == "false" {
502+ return false
503+ }
504+ return guess
505+}
506+
507+func classify(b blob, detectable string) (string, string, bool) {
508+ lang := b.lang
509+ if lang == "" && len(b.guesses) > 0 {
510+ lang = b.guesses[0]
511+ }
512+ if lang == "" {
513+ l, ok := languages[strings.ToLower(path.Ext(b.name))]
514+ return l[0], l[1], ok && detectable != "unset" && detectable != "false"
515+ }
516+ if group := enry.GetLanguageGroup(lang); group != "" {
517+ lang = group
518+ }
519+ switch detectable {
520+ case "set", "true":
521+ case "unset", "false":
522+ return "", "", false
523+ default:
524+ if enry.GetLanguageType(lang) != enry.Programming {
525+ return "", "", false
526+ }
527+ }
528+ return lang, enry.GetColor(lang), true
529+}
530+
531+func (g Git) linguist(head string, blobs []blob) map[string]map[string]string {
532+ var names strings.Builder
533+ for _, b := range blobs {
534+ names.WriteString(b.name + "\x00")
535+ }
536+ out, err := g.run([]byte(names.String()), "check-attr", "--source="+head, "-z", "--stdin",
537+ "linguist-vendored", "linguist-generated", "linguist-documentation", "linguist-detectable", "linguist-language")
538+ if err != nil {
539+ return nil
540+ }
541+ attrs := map[string]map[string]string{}
542+ f := strings.Split(string(out), "\x00")
543+ for i := 0; i+2 < len(f); i += 3 {
544+ if f[i+2] == "unspecified" {
545+ continue
546+ }
547+ if attrs[f[i]] == nil {
548+ attrs[f[i]] = map[string]string{}
549+ }
550+ attrs[f[i]][f[i+1]] = f[i+2]
551+ }
552+ return attrs
553+}
554+
555+func topLanguages(langs []language, limit int) []language {
556+ if len(langs) <= limit+1 {
557+ return langs
558+ }
559+ top := slices.Clone(langs[:limit])
560+ other := language{Name: "Other", Colour: "#cccccc"}
561+ for _, l := range langs[limit:] {
562+ other.Bytes += l.Bytes
563+ other.Percent += l.Percent
564+ }
565+ return append(top, other)
566+}

shell.go → forge/shell.go renamed

@@ -1,4 +1,4 @@
1-package main
1+package forge
22
33 import (
44 "fmt"

smart.go → forge/smart.go renamed

@@ -1,4 +1,4 @@
1-package main
1+package forge
22
33 import (
44 "compress/gzip"

store.go → forge/store.go renamed

@@ -1,4 +1,4 @@
1-package main
1+package forge
22
33 import (
44 "crypto/rand"

totp.go → forge/totp.go renamed

@@ -1,4 +1,4 @@
1-package main
1+package forge
22
33 import (
44 "crypto/hmac"

unit_test.go → forge/unit_test.go renamed

@@ -1,4 +1,4 @@
1-package main
1+package forge
22
33 import (
44 "net"

web.go → forge/web.go renamed

@@ -1,9 +1,8 @@
1-package main
1+package forge
22
33 import (
44 "bytes"
55 "crypto/sha256"
6- "embed"
76 "encoding/hex"
87 "errors"
98 "fmt"
@@ -33,13 +32,14 @@ import (
3332 highlighting "github.com/yuin/goldmark-highlighting/v2"
3433 "github.com/yuin/goldmark/extension"
3534 gmhtml "github.com/yuin/goldmark/renderer/html"
36-)
3735
38-//go:embed templates/*.html
39-var templateFS embed.FS
36+ "gitpocket/web"
37+)
4038
41-//go:embed static
42-var staticFS embed.FS
39+var (
40+ templateFS = web.Templates
41+ staticFS = web.Static
42+)
4343
4444 type Server struct {
4545 mailer *Mailer
@@ -128,16 +128,16 @@ func newServer(store *Store, root, host, name string, sshUser string) *Server {
128128 },
129129 "join": func(parts []string, upto int) string { return strings.Join(parts[:upto], "/") },
130130 }
131- names, _ := fs.Glob(templateFS, "templates/*.html")
131+ names, _ := fs.Glob(templateFS, "templates/*.tmpl")
132132 for _, name := range names {
133133 base := filepath.Base(name)
134- if base == "layout.html" {
134+ if base == "layout.tmpl" {
135135 continue
136136 }
137- if base == "meta.html" {
137+ if base == "meta.tmpl" {
138138 continue
139139 }
140- s.pages[strings.TrimSuffix(base, ".html")] = template.Must(template.New("layout.html").Funcs(funcs).ParseFS(templateFS, "templates/layout.html", "templates/meta.html", name))
140+ s.pages[strings.TrimSuffix(base, ".tmpl")] = template.Must(template.New("layout.tmpl").Funcs(funcs).ParseFS(templateFS, "templates/layout.tmpl", "templates/meta.tmpl", name))
141141 }
142142 s.markdown = goldmark.New(goldmark.WithExtensions(extension.GFM, mathjax.MathJax,
143143 highlighting.NewHighlighting(highlighting.WithGuessLanguage(true), highlighting.WithFormatOptions(chromahtml.WithClasses(true)))),
@@ -1130,7 +1130,7 @@ func (s *Server) summary(w http.ResponseWriter, r *http.Request, repo Repo) {
11301130 if !rp.Empty {
11311131 sum := s.summaryData(repo, g, rp)
11321132 data["Commits"], data["Branches"], data["Tags"] = sum.commits, sum.branches, sum.tags
1133- data["Languages"] = s.repoLanguages(repo)
1133+ data["Languages"] = topLanguages(s.repoLanguages(repo), 6)
11341134 if sum.readmeName != "" {
11351135 data["ReadmeName"] = sum.readmeName
11361136 if sum.readme != "" {

go.mod modified

@@ -3,26 +3,28 @@ module gitpocket
33 go 1.27.1
44
55 require (
6+ github.com/alecthomas/chroma/v2 v2.27.0
7+ github.com/go-enry/go-enry/v2 v2.9.6
8+ github.com/litao91/goldmark-mathjax v0.0.0-20210217064022-a43cf739a50f
69 github.com/microcosm-cc/bluemonday v1.0.27
10+ github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
711 github.com/yuin/goldmark v1.8.7
12+ github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
813 golang.org/x/crypto v0.59.0
14+ golang.org/x/image v0.48.0
915 modernc.org/sqlite v1.62.0
1016 )
1117
1218 require (
13- github.com/alecthomas/chroma/v2 v2.27.0 // indirect
1419 github.com/aymerick/douceur v0.2.0 // indirect
1520 github.com/dlclark/regexp2/v2 v2.2.1 // indirect
1621 github.com/dustin/go-humanize v1.1.0 // indirect
22+ github.com/go-enry/go-oniguruma v1.2.1 // indirect
1723 github.com/google/uuid v1.6.0 // indirect
1824 github.com/gorilla/css v1.0.1 // indirect
19- github.com/litao91/goldmark-mathjax v0.0.0-20210217064022-a43cf739a50f // indirect
2025 github.com/mattn/go-isatty v0.0.24 // indirect
2126 github.com/ncruces/go-strftime v1.1.0 // indirect
2227 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
23- github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
24- github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc // indirect
25- golang.org/x/image v0.48.0 // indirect
2628 golang.org/x/net v0.62.0 // indirect
2729 golang.org/x/sys v0.50.0 // indirect
2830 modernc.org/libc v1.80.1 // indirect

go.sum modified

@@ -1,10 +1,15 @@
1+github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
2+github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
13 github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
24 github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
35 github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
46 github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
7+github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
8+github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
59 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
610 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
711 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
12+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
813 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
914 github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
1015 github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
@@ -12,6 +17,10 @@ github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee
1217 github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
1318 github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
1419 github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
20+github.com/go-enry/go-enry/v2 v2.9.6 h1:np63eOtMV56zfYDHnFVgpEVOk8fr2kmylcMnAZUDbSs=
21+github.com/go-enry/go-enry/v2 v2.9.6/go.mod h1:9yrj4ES1YrbNb1Wb7/PWYr2bpaCXUGRt0uafN0ISyG8=
22+github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo=
23+github.com/go-enry/go-oniguruma v1.2.1/go.mod h1:bWDhYP+S6xZQgiRL7wlTScFYBe023B6ilRZbCAD5Hf4=
1524 github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
1625 github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
1726 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -20,6 +29,8 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
2029 github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
2130 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
2231 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
32+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
33+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
2334 github.com/litao91/goldmark-mathjax v0.0.0-20210217064022-a43cf739a50f h1:plCPYXRXDCO57qjqegCzaVf1t6aSbgCMD+zfz18POfs=
2435 github.com/litao91/goldmark-mathjax v0.0.0-20210217064022-a43cf739a50f/go.mod h1:leg+HM7jUS84JYuY120zmU68R6+UeU6uZ/KAW7cViKE=
2536 github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
@@ -28,14 +39,21 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX
2839 github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
2940 github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
3041 github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
42+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
3143 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
3244 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
3345 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
3446 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
3547 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
3648 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
49+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
50+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
3751 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
3852 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
53+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
54+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
55+github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
56+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
3957 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
4058 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
4159 github.com/yuin/goldmark v1.8.7 h1:XT/tNqCIbabh0ln675w4x3dv04kQBKi8NwYqi9IJaxI=
@@ -59,7 +77,10 @@ golang.org/x/term v0.48.0/go.mod h1:BRDLHCZr2SUjIeBHHpRyQeC9xzSAVbzY2rvKgafnHf4=
5977 golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
6078 golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
6179 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
80+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
6281 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
82+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
83+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
6384 modernc.org/cc/v4 v4.30.6-0.20261101114202-1b9d1a076f6b h1:WE5SLOURcNd8s6uPR/n4WV4weQxxYKi5OpEZkfNVvdE=
6485 modernc.org/cc/v4 v4.30.6-0.20261101114202-1b9d1a076f6b/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
6586 modernc.org/ccgo/v4 v4.38.1 h1:wdxJVBuFT8JufZnnQyS6Bbz3NOWhViD0xJOGR96Bpd4=

main.go modified

@@ -1,251 +1,7 @@
11 package main
22
3-import (
4- "bufio"
5- "flag"
6- "fmt"
7- "log"
8- "net"
9- "net/http"
10- "os"
11- "path/filepath"
12- "strconv"
13- "strings"
14- "time"
15-)
3+import "gitpocket/forge"
164
175 func main() {
18- root := flag.String("root", "/srv/gitpocket", "data directory holding gitpocket.db and repos/")
19- flag.Usage = func() {
20- fmt.Fprintln(os.Stderr, `usage: gitpocket [-root dir] command
21-
22- serve [-listen addr] [-host name] [-name text] [-ssh-user name] [-proxy cidr,...] run the web server
23- keys user home type key sshd AuthorizedKeysCommand
24- shell user forced command for ssh git access
25- invite print a single use registration code
26- registration [open|invite|closed] show or set who may register
27- admin name | unadmin name grant or revoke site administration
28- quota [size] show or set the per user disk quota, 0 disables
29- user name create a user, password read from stdin
30- passwd name set a user's password, read from stdin
31- ban name | unban name block or restore an account
32- delete name remove an account and its repositories
33- feature owner/repo... | -owner/repo set or clear the featured projects, in order
34- org name [+user|-user]... create an organization and add or remove members
35- mail address send a test message through the configured mailer
36- backup dir | file.db snapshot the database and bundle every changed repository into dir`)
37- }
38- flag.Parse()
39- args := flag.Args()
40- if len(args) == 0 {
41- flag.Usage()
42- os.Exit(2)
43- }
44- switch args[0] {
45- case "serve":
46- serve(*root, args[1:])
47- case "keys":
48- binary, _ := os.Executable()
49- os.Exit(keys(*root, binary, args[1:]))
50- case "hook":
51- if len(args) != 3 {
52- os.Exit(2)
53- }
54- os.Exit(hook(*root, args[1], args[2]))
55- case "shell":
56- if len(args) != 2 {
57- os.Exit(2)
58- }
59- os.Exit(shell(*root, args[1]))
60- case "quota":
61- if len(args) == 2 {
62- n, err := parseSize(args[1])
63- if err != nil {
64- log.Fatal(err)
65- }
66- if err := writeQuota(*root, n); err != nil {
67- log.Fatal(err)
68- }
69- }
70- if q := readQuota(*root); q == 0 {
71- fmt.Println("no quota")
72- } else {
73- fmt.Println(humanSize(q))
74- }
75- case "registration":
76- store := mustStore(*root)
77- if len(args) == 2 {
78- if !validRegistration(args[1]) {
79- log.Fatal("registration is open, invite or closed")
80- }
81- if err := store.SetSetting("registration", args[1]); err != nil {
82- log.Fatal(err)
83- }
84- }
85- fmt.Println(store.Registration())
86- case "admin", "unadmin":
87- if len(args) != 2 {
88- os.Exit(2)
89- }
90- if err := mustStore(*root).SetAdmin(args[1], args[0] == "admin"); err != nil {
91- log.Fatal("no such user")
92- }
93- case "invite":
94- store := mustStore(*root)
95- code, err := store.CreateInvite()
96- if err != nil {
97- log.Fatal(err)
98- }
99- fmt.Println(code)
100- case "user":
101- if len(args) != 2 {
102- os.Exit(2)
103- }
104- store := mustStore(*root)
105- password := readLine()
106- if _, err := store.CreateUser(args[1], password); err != nil {
107- log.Fatal(err)
108- }
109- case "passwd":
110- if len(args) != 2 {
111- os.Exit(2)
112- }
113- store := mustStore(*root)
114- u, err := store.UserByName(args[1])
115- if err != nil {
116- log.Fatal("no such user")
117- }
118- password := readLine()
119- if err := store.SetPassword(u.ID, password); err != nil {
120- log.Fatal(err)
121- }
122- store.DeleteSessions(u.ID)
123- case "backup":
124- if len(args) != 2 {
125- os.Exit(2)
126- }
127- if err := backup(*root, args[1], 14); err != nil {
128- log.Fatal(err)
129- }
130- case "mail":
131- if len(args) != 2 {
132- os.Exit(2)
133- }
134- if err := loadMailer(*root).Send(args[1], "test message from gitpocket", "Mail delivery from this forge works.\n"); err != nil {
135- log.Fatal(err)
136- }
137- fmt.Println("sent")
138- case "org":
139- if len(args) < 2 {
140- os.Exit(2)
141- }
142- store := mustStore(*root)
143- org, err := store.UserByName(args[1])
144- if err != nil {
145- if org, err = store.CreateOrg(args[1]); err != nil {
146- log.Fatal(err)
147- }
148- } else if !store.IsOrg(org.ID) {
149- log.Fatalf("~%s is a user, not an organization", args[1])
150- }
151- for _, spec := range args[2:] {
152- name := strings.TrimPrefix(strings.TrimLeft(spec, "+-"), "~")
153- u, err := store.UserByName(name)
154- if err != nil || store.IsOrg(u.ID) {
155- log.Fatalf("%s: no such user", spec)
156- }
157- if err := store.SetMember(org.ID, u.ID, !strings.HasPrefix(spec, "-")); err != nil {
158- log.Fatal(err)
159- }
160- }
161- case "feature":
162- if len(args) < 2 {
163- os.Exit(2)
164- }
165- store := mustStore(*root)
166- for i, spec := range args[1:] {
167- owner, name, ok := strings.Cut(strings.TrimPrefix(spec, "~"), "/")
168- rank := i + 1
169- if strings.HasPrefix(spec, "-") {
170- owner, name, ok = strings.Cut(strings.TrimPrefix(spec, "-~"), "/")
171- rank = 0
172- }
173- if !ok {
174- log.Fatalf("%s: expected owner/repo", spec)
175- }
176- if err := store.SetFeatured(owner, name, rank); err != nil {
177- log.Fatalf("%s: no such repository", spec)
178- }
179- }
180- case "ban", "unban":
181- if len(args) != 2 {
182- os.Exit(2)
183- }
184- if err := mustStore(*root).SetBanned(args[1], args[0] == "ban"); err != nil {
185- log.Fatal(err)
186- }
187- case "delete":
188- if len(args) != 2 {
189- os.Exit(2)
190- }
191- store := mustStore(*root)
192- u, err := store.UserByName(args[1])
193- if err != nil {
194- log.Fatal("no such user")
195- }
196- if err := store.DeleteUser(args[1]); err != nil {
197- log.Fatal(err)
198- }
199- os.RemoveAll(filepath.Join(*root, "repos", args[1]))
200- os.Remove(filepath.Join(*root, "avatars", strconv.FormatInt(u.ID, 10)))
201- default:
202- flag.Usage()
203- os.Exit(2)
204- }
205-}
206-
207-func mustStore(root string) *Store {
208- if err := os.MkdirAll(filepath.Join(root, "repos"), 0o755); err != nil {
209- log.Fatal(err)
210- }
211- store, err := openStore(filepath.Join(root, "gitpocket.db"), false)
212- if err != nil {
213- log.Fatal(err)
214- }
215- return store
216-}
217-
218-func serve(root string, args []string) {
219- fs := flag.NewFlagSet("serve", flag.ExitOnError)
220- listen := fs.String("listen", "127.0.0.1:3200", "address to listen on")
221- host := fs.String("host", "localhost", "public hostname used in clone urls")
222- name := fs.String("name", "gitpocket", "site name shown in the header")
223- sshUser := fs.String("ssh-user", "git", "unix user that serves git over ssh, shown in clone urls")
224- proxies := fs.String("proxy", "", "comma separated networks besides loopback whose X-Forwarded-For is trusted")
225- fs.Parse(args)
226- for _, cidr := range strings.Split(*proxies, ",") {
227- if cidr = strings.TrimSpace(cidr); cidr == "" {
228- continue
229- }
230- _, n, err := net.ParseCIDR(cidr)
231- if err != nil {
232- log.Fatalf("-proxy %s: %v", cidr, err)
233- }
234- trustedProxies = append(trustedProxies, n)
235- }
236- store := mustStore(root)
237- srv := &http.Server{
238- Addr: *listen,
239- Handler: newServer(store, root, *host, *name, *sshUser).handler(),
240- ReadHeaderTimeout: 10 * time.Second,
241- IdleTimeout: 2 * time.Minute,
242- MaxHeaderBytes: 64 << 10,
243- }
244- log.Printf("gitpocket listening on %s for %s", *listen, *host)
245- log.Fatal(srv.ListenAndServe())
246-}
247-
248-func readLine() string {
249- line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
250- return strings.TrimRight(line, "\r\n")
6+ forge.Main()
2517 }

static/crop.js → web/static/crop.js renamed

static/editor.js → web/static/editor.js renamed

static/fonts/sans-400-italic.woff2 → web/static/fonts/sans-400-italic.woff2 renamed

static/fonts/sans-400-normal.woff2 → web/static/fonts/sans-400-normal.woff2 renamed

static/fonts/sans-600-normal.woff2 → web/static/fonts/sans-600-normal.woff2 renamed

static/fonts/sans-700-normal.woff2 → web/static/fonts/sans-700-normal.woff2 renamed

static/fonts/sans.txt → web/static/fonts/sans.txt renamed

static/fonts/variable.woff2 → web/static/fonts/variable.woff2 renamed

static/icon.png → web/static/icon.png renamed

static/katex/LICENSE.txt → web/static/katex/LICENSE.txt renamed

static/katex/auto-render.min.js → web/static/katex/auto-render.min.js renamed

static/katex/fonts/KaTeX_AMS-Regular.woff2 → web/static/katex/fonts/KaTeX_AMS-Regular.woff2 renamed

static/katex/fonts/KaTeX_Caligraphic-Bold.woff2 → web/static/katex/fonts/KaTeX_Caligraphic-Bold.woff2 renamed

static/katex/fonts/KaTeX_Caligraphic-Regular.woff2 → web/static/katex/fonts/KaTeX_Caligraphic-Regular.woff2 renamed

static/katex/fonts/KaTeX_Fraktur-Bold.woff2 → web/static/katex/fonts/KaTeX_Fraktur-Bold.woff2 renamed

static/katex/fonts/KaTeX_Fraktur-Regular.woff2 → web/static/katex/fonts/KaTeX_Fraktur-Regular.woff2 renamed

static/katex/fonts/KaTeX_Main-Bold.woff2 → web/static/katex/fonts/KaTeX_Main-Bold.woff2 renamed

static/katex/fonts/KaTeX_Main-BoldItalic.woff2 → web/static/katex/fonts/KaTeX_Main-BoldItalic.woff2 renamed

static/katex/fonts/KaTeX_Main-Italic.woff2 → web/static/katex/fonts/KaTeX_Main-Italic.woff2 renamed

static/katex/fonts/KaTeX_Main-Regular.woff2 → web/static/katex/fonts/KaTeX_Main-Regular.woff2 renamed

static/katex/fonts/KaTeX_Math-BoldItalic.woff2 → web/static/katex/fonts/KaTeX_Math-BoldItalic.woff2 renamed

static/katex/fonts/KaTeX_Math-Italic.woff2 → web/static/katex/fonts/KaTeX_Math-Italic.woff2 renamed

static/katex/fonts/KaTeX_SansSerif-Bold.woff2 → web/static/katex/fonts/KaTeX_SansSerif-Bold.woff2 renamed

static/katex/fonts/KaTeX_SansSerif-Italic.woff2 → web/static/katex/fonts/KaTeX_SansSerif-Italic.woff2 renamed

static/katex/fonts/KaTeX_SansSerif-Regular.woff2 → web/static/katex/fonts/KaTeX_SansSerif-Regular.woff2 renamed

static/katex/fonts/KaTeX_Script-Regular.woff2 → web/static/katex/fonts/KaTeX_Script-Regular.woff2 renamed

static/katex/fonts/KaTeX_Size1-Regular.woff2 → web/static/katex/fonts/KaTeX_Size1-Regular.woff2 renamed

static/katex/fonts/KaTeX_Size2-Regular.woff2 → web/static/katex/fonts/KaTeX_Size2-Regular.woff2 renamed

static/katex/fonts/KaTeX_Size3-Regular.woff2 → web/static/katex/fonts/KaTeX_Size3-Regular.woff2 renamed

static/katex/fonts/KaTeX_Size4-Regular.woff2 → web/static/katex/fonts/KaTeX_Size4-Regular.woff2 renamed

static/katex/fonts/KaTeX_Typewriter-Regular.woff2 → web/static/katex/fonts/KaTeX_Typewriter-Regular.woff2 renamed

static/katex/katex.min.css → web/static/katex/katex.min.css renamed

static/katex/katex.min.js → web/static/katex/katex.min.js renamed

static/logo.png → web/static/logo.png renamed

static/math.js → web/static/math.js renamed

static/style.css → web/static/style.css renamed

@@ -394,10 +394,11 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere}
394394 .hit.org .hitavatar img{border-radius:6px}
395395 .hitavatar img{display:flex;width:3rem;height:3rem;border-radius:0;object-fit:cover;border:1px solid var(--line)}
396396 .hit .bio{color:var(--fg2);overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
397-.langbar{display:flex;height:.5rem;border-radius:4px;overflow:hidden;margin:.2rem 0 .5rem;background:var(--line)}
397+.langbar{display:flex;gap:2px;height:.5rem;border-radius:4px;overflow:hidden;margin:.2rem 0 .6rem}
398398 .langbar span{display:block;height:100%}
399399 .langlist{list-style:none;margin:0 0 1.2rem;padding:0;font-size:.85rem;display:flex;flex-wrap:wrap;gap:.2rem 1rem}
400-.langlist .pct{color:var(--muted);margin-left:.25rem}
400+.langlist b{font-weight:600}
401+.langlist .pct{color:var(--muted);margin-left:.3rem}
401402 @media(max-width:800px){.bigsearch{margin-left:0}.searchpage{grid-template-columns:1fr;gap:.6rem}.navsearch{order:5;flex-basis:100%;margin:0}.facets{display:flex;flex-direction:column;gap:.5rem}.facetbox{padding:.4rem}.facetbox h4{display:none}.facetlist{flex-direction:row;flex-wrap:wrap;gap:.35rem}.facetlist a{border:1px solid var(--line);padding:.2rem .55rem}.facetlist a.active{border-color:var(--accent);box-shadow:none}}
402403 .facetbox{margin-bottom:1.4rem}
403404 .facetbox h4{font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin:.15rem .4rem .45rem;font-weight:600}

templates/admin.html → web/templates/admin.tmpl renamed

templates/appearance.html → web/templates/appearance.tmpl renamed

templates/blame.html → web/templates/blame.tmpl renamed

templates/blob.html → web/templates/blob.tmpl renamed

templates/commit.html → web/templates/commit.tmpl renamed

templates/compare.html → web/templates/compare.tmpl renamed

templates/create.html → web/templates/create.tmpl renamed

templates/edit.html → web/templates/edit.tmpl renamed

templates/error.html → web/templates/error.tmpl renamed

templates/index.html → web/templates/index.tmpl renamed

templates/interdiff.html → web/templates/interdiff.tmpl renamed

templates/issue.html → web/templates/issue.tmpl renamed

templates/issues.html → web/templates/issues.tmpl renamed

templates/keys.html → web/templates/keys.tmpl renamed

templates/layout.html → web/templates/layout.tmpl renamed

templates/log.html → web/templates/log.tmpl renamed

templates/login.html → web/templates/login.tmpl renamed

templates/message.html → web/templates/message.tmpl renamed

templates/meta.html → web/templates/meta.tmpl renamed

templates/newissue.html → web/templates/newissue.tmpl renamed

templates/neworg.html → web/templates/neworg.tmpl renamed

templates/newpull.html → web/templates/newpull.tmpl renamed

templates/notifications.html → web/templates/notifications.tmpl renamed

templates/org.html → web/templates/org.tmpl renamed

templates/orgsettings.html → web/templates/orgsettings.tmpl renamed

templates/pull.html → web/templates/pull.tmpl renamed

templates/pulls.html → web/templates/pulls.tmpl renamed

templates/recovery.html → web/templates/recovery.tmpl renamed

templates/refs.html → web/templates/refs.tmpl renamed

templates/register.html → web/templates/register.tmpl renamed

templates/repo.html → web/templates/repo.tmpl renamed

@@ -18,7 +18,7 @@ git push -u origin {{$p.Ref}}</pre>
1818 <div class="clone">
1919 {{with .Data.Languages}}<h3>languages</h3>
2020 <div class="langbar">{{range .}}<span style="width:{{printf "%.2f" .Percent}}%;background:{{.Colour}}" title="{{.Name}} {{printf "%.1f" .Percent}}%"></span>{{end}}</div>
21-<ul class="langlist">{{range .}}<li><i class="dot" style="background:{{.Colour}}"></i>{{.Name}}<span class="pct">{{printf "%.1f" .Percent}}%</span></li>{{end}}</ul>
21+<ul class="langlist">{{range .}}<li><i class="dot" style="background:{{.Colour}}"></i><b>{{.Name}}</b><span class="pct">{{printf "%.1f" .Percent}}%</span></li>{{end}}</ul>
2222 <h3 class="rule">clone</h3>{{else}}<h3>clone</h3>{{end}}
2323 <strong>read only</strong>
2424 <div><a href="https://{{.Host}}/{{$p.Repo.Path}}">https://{{.Host}}/{{$p.Repo.Path}}</a></div>

templates/repos.html → web/templates/repos.tmpl renamed

templates/reposettings.html → web/templates/reposettings.tmpl renamed

templates/reset.html → web/templates/reset.tmpl renamed

templates/resetpassword.html → web/templates/resetpassword.tmpl renamed

templates/search.html → web/templates/search.tmpl renamed

templates/security.html → web/templates/security.tmpl renamed

templates/settings.html → web/templates/settings.tmpl renamed

templates/stars.html → web/templates/stars.tmpl renamed

templates/tokens.html → web/templates/tokens.tmpl renamed

templates/totp.html → web/templates/totp.tmpl renamed

templates/totpsetup.html → web/templates/totpsetup.tmpl renamed

templates/tree.html → web/templates/tree.tmpl renamed

templates/user.html → web/templates/user.tmpl renamed

web/web.go added

@@ -0,0 +1,9 @@
1+package web
2+
3+import "embed"
4+
5+//go:embed templates/*.tmpl
6+var Templates embed.FS
7+
8+//go:embed static
9+var Static embed.FS