~gitpocket/gitpocket

1

feat(web): featured landing page and backups

commit
b8a47d8aa84e4a4c975601441fb5e2337127b98f browse tree
parent
3ff63a0c43f8fe21cd83d886a933a5c7aae93a4b
author
obscurejames <354576047+obscurejames@users.noreply.github.com>
signature
Signed by: ~james SHA256:KlzJ0P0gvJcnOYpaxD5yCUw52ImHttTKRjVAEnnYnKg
date
2026-10-04 13:20 UTC

8 files changed, +151 −15

README.md modified

@@ -68,6 +68,15 @@ sudo -u git basin -root /srv/basin invite
6868
6969 If you would rather let anyone sign up then `serve -open` skips the invite step entirely. `basin user <name>` creates an account from the command line with the password read from stdin, and `-ssh-user` changes the account name that appears in clone URLs if you did not call yours `git`.
7070
71+Mail is configured in `smtp.json` under the root directory and is read once at startup. Plain SMTP takes `host`, `port`, `user`, `password`, `from` and `starttls`, but many VPS providers block every outbound submission port, so `api` and `key` post each message as JSON to a Resend compatible HTTPS endpoint instead.
72+
73+```
74+{"api": "https://api.resend.com/emails", "key": "re_...", "from": "basin <noreply@git.example.org>"}
75+sudo -u git basin -root /srv/basin mail you@example.org
76+```
77+
78+`basin feature ~you/a ~you/b` chooses the projects on the logged out front page in that order, `-~you/a` drops one again, and with none chosen the page falls back to the most recently pushed. `basin backup file` writes a consistent copy of the database with `vacuum into`, which is safe to run from a timer while the server is live.
79+
7180 ## Use
7281
7382 ```

mail.go modified

@@ -1,12 +1,15 @@
11 package main
22
33 import (
4+ "bytes"
45 "crypto/tls"
56 "encoding/json"
67 "errors"
78 "fmt"
9+ "io"
810 "log"
911 "net"
12+ "net/http"
1013 "net/smtp"
1114 "os"
1215 "path/filepath"
@@ -22,6 +25,8 @@ type Mailer struct {
2225 From string `json:"from"`
2326 StartTLS bool `json:"starttls"`
2427 Insecure bool `json:"insecure"`
28+ API string `json:"api"`
29+ Key string `json:"key"`
2530 }
2631
2732 func loadMailer(root string) *Mailer {
@@ -30,7 +35,7 @@ func loadMailer(root string) *Mailer {
3035 return nil
3136 }
3237 var m Mailer
33- if json.Unmarshal(b, &m) != nil || m.Host == "" || m.From == "" {
38+ if json.Unmarshal(b, &m) != nil || (m.Host == "" && m.API == "") || m.From == "" {
3439 return nil
3540 }
3641 if m.Port == 0 {
@@ -43,6 +48,9 @@ func (m *Mailer) Send(to, subject, body string) error {
4348 if m == nil {
4449 return errors.New("mail is not configured")
4550 }
51+ if m.API != "" {
52+ return m.post(to, subject, body)
53+ }
4654 addr := net.JoinHostPort(m.Host, fmt.Sprint(m.Port))
4755 tlsConfig := &tls.Config{ServerName: m.Host, InsecureSkipVerify: m.Insecure}
4856 var client *smtp.Client
@@ -80,8 +88,8 @@ func (m *Mailer) Send(to, subject, body string) error {
8088 if err != nil {
8189 return err
8290 }
83- msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n%s\r\n",
84- m.From, to, strings.ReplaceAll(subject, "\n", " "), time.Now().Format(time.RFC1123Z), body)
91+ msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMessage-ID: <%d.%s>\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n%s\r\n",
92+ m.From, to, strings.ReplaceAll(subject, "\n", " "), time.Now().Format(time.RFC1123Z), time.Now().UnixNano(), m.domain(), body)
8593 if _, err := w.Write([]byte(msg)); err != nil {
8694 return err
8795 }
@@ -101,3 +109,32 @@ func (s *Server) notify(to, subject, body string) {
101109 }
102110 }()
103111 }
112+
113+func (m *Mailer) domain() string {
114+ from := m.From
115+ if i := strings.LastIndex(from, "<"); i >= 0 {
116+ from = strings.TrimSuffix(from[i+1:], ">")
117+ }
118+ _, d, _ := strings.Cut(from, "@")
119+ return d
120+}
121+
122+func (m *Mailer) post(to, subject, body string) error {
123+ payload, _ := json.Marshal(map[string]any{"from": m.From, "to": []string{to}, "subject": strings.ReplaceAll(subject, "\n", " "), "text": body})
124+ req, err := http.NewRequest("POST", m.API, bytes.NewReader(payload))
125+ if err != nil {
126+ return err
127+ }
128+ req.Header.Set("Authorization", "Bearer "+m.Key)
129+ req.Header.Set("Content-Type", "application/json")
130+ resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
131+ if err != nil {
132+ return err
133+ }
134+ defer resp.Body.Close()
135+ if resp.StatusCode/100 != 2 {
136+ msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
137+ return fmt.Errorf("mail api: %s: %s", resp.Status, strings.TrimSpace(string(msg)))
138+ }
139+ return nil
140+}

main.go modified

@@ -8,6 +8,7 @@ import (
88 "os"
99 "path/filepath"
1010 "strconv"
11+ "strings"
1112 "time"
1213 )
1314
@@ -24,7 +25,10 @@ func main() {
2425 user name create a user, password read from stdin
2526 passwd name set a user's password, read from stdin
2627 ban name | unban name block or restore an account
27- delete name remove an account and its repositories`)
28+ delete name remove an account and its repositories
29+ feature owner/repo... | -owner/repo set or clear the featured projects, in order
30+ mail address send a test message through the configured mailer
31+ backup file write a consistent copy of the database`)
2832 }
2933 flag.Parse()
3034 args := flag.Args()
@@ -95,6 +99,40 @@ func main() {
9599 log.Fatal(err)
96100 }
97101 store.DeleteSessions(u.ID)
102+ case "backup":
103+ if len(args) != 2 {
104+ os.Exit(2)
105+ }
106+ if err := mustStore(*root).Backup(args[1]); err != nil {
107+ log.Fatal(err)
108+ }
109+ case "mail":
110+ if len(args) != 2 {
111+ os.Exit(2)
112+ }
113+ if err := loadMailer(*root).Send(args[1], "test message from basin", "Mail delivery from this forge works.\n"); err != nil {
114+ log.Fatal(err)
115+ }
116+ fmt.Println("sent")
117+ case "feature":
118+ if len(args) < 2 {
119+ os.Exit(2)
120+ }
121+ store := mustStore(*root)
122+ for i, spec := range args[1:] {
123+ owner, name, ok := strings.Cut(strings.TrimPrefix(spec, "~"), "/")
124+ rank := i + 1
125+ if strings.HasPrefix(spec, "-") {
126+ owner, name, ok = strings.Cut(strings.TrimPrefix(spec, "-~"), "/")
127+ rank = 0
128+ }
129+ if !ok {
130+ log.Fatalf("%s: expected owner/repo", spec)
131+ }
132+ if err := store.SetFeatured(owner, name, rank); err != nil {
133+ log.Fatalf("%s: no such repository", spec)
134+ }
135+ }
98136 case "ban", "unban":
99137 if len(args) != 2 {
100138 os.Exit(2)

static/style.css modified

@@ -291,3 +291,22 @@ a.label{opacity:.75}a.label.on,a.label:hover{opacity:1;text-decoration:none}
291291 .inlineform{display:inline}
292292 .linkbutton{background:none;border:0;padding:0;font:inherit;color:var(--muted);cursor:pointer}
293293 .linkbutton:hover{color:var(--del);text-decoration:underline}
294+nav.site .brand{display:inline-flex;align-items:center;gap:.4rem}
295+.ring{display:inline-block;width:1.05rem;height:1.05rem;border:2px solid var(--fg);border-radius:50%}
296+.landing{margin:0}
297+.landing>p{margin:.2rem 0 1rem}
298+.landing a.arrow{text-decoration:underline}
299+h3.featured{font-size:1.3rem;font-weight:400;margin:2rem 0 .5rem}
300+.featuredgrid{display:grid;grid-template-columns:1fr 1fr;gap:.9rem 1rem}
301+.feature{background:var(--soft);padding:.5rem .5rem .9rem}
302+.feature h4{margin:0 0 .4rem;font-weight:700}
303+.feature h4 a{color:var(--link)}
304+.feature h4 a.owner{color:var(--muted)}
305+.feature blockquote{margin:0 0 .5rem;padding:0 0 0 .5rem;border-left:2px solid #aaa}
306+.feature .tags{margin:0}
307+.feature .tags a{color:var(--muted);text-decoration:underline;margin-right:.75rem}
308+.landing .more{display:flex;justify-content:flex-end;margin-top:.9rem}
309+.landing .more .btn{min-width:20rem;text-align:center}
310+@media (max-width:700px){.featuredgrid{grid-template-columns:1fr}.landing .more .btn{min-width:0;width:100%}}
311+.readme video,.readme img{max-width:100%;height:auto}
312+.auth{white-space:nowrap}

store.go modified

@@ -75,6 +75,7 @@ var migrations = []string{
7575 `alter table users add column totp text not null default ''`,
7676 `alter table users add column recovery text not null default ''`,
7777 `alter table pulls add column draft integer not null default 0`,
78+ `alter table repos add column featured integer not null default 0`,
7879 }
7980
8081 const extraSchema = `
@@ -570,6 +571,30 @@ func scanRepos(rows *sql.Rows) ([]Repo, error) {
570571
571572 const repoColumns = `r.id, r.name, r.description, r.private, r.tags, r.parent, r.archived, (select count(*) from stars st where st.repo = r.id), r.created, u.id, u.name, u.created from repos r join users u on u.id = r.owner`
572573
574+func (s *Store) Backup(path string) error {
575+ _, err := s.db.Exec(`vacuum into ?`, path)
576+ return err
577+}
578+
579+func (s *Store) FeaturedRepos() ([]Repo, error) {
580+ rows, err := s.db.Query(`select ` + repoColumns + ` where r.private = 0 and r.featured > 0 order by r.featured`)
581+ if err != nil {
582+ return nil, err
583+ }
584+ return scanRepos(rows)
585+}
586+
587+func (s *Store) SetFeatured(owner, name string, rank int) error {
588+ res, err := s.db.Exec(`update repos set featured = ? where name = ? and owner = (select id from users where name = ?)`, rank, name, owner)
589+ if err != nil {
590+ return err
591+ }
592+ if n, _ := res.RowsAffected(); n == 0 {
593+ return ErrNotFound
594+ }
595+ return nil
596+}
597+
573598 func (s *Store) RepoByID(id int64) (Repo, error) {
574599 rows, err := s.db.Query(`select `+repoColumns+` where r.id = ?`, id)
575600 if err != nil {

templates/index.html modified

@@ -9,17 +9,19 @@
99 <section class="panel"><h3>Keys and tokens</h3><p>Register SSH public keys for pushing and create access tokens for HTTPS pushes or scripts.</p><a class="btn" href="/settings/keys">Manage keys ▸</a></section>
1010 </div>
1111 {{else}}
12-<div class="cards">
13-<section class="panel"><h3>Host a repository</h3><p>Registration is by invitation. Once you have an account, push any repository and it gets a page with its history, refs and README.</p><a class="btn primary" href="/register">Register ▸</a></section>
14-<section class="panel"><h3>Browse the code</h3><p>Every public repository can be cloned anonymously over HTTPS and read in the browser with syntax highlighting.</p><a class="btn" href="/projects">Browse public projects ▸</a></section>
15-<section class="panel"><h3>Already a member?</h3><p>Log in to see your repositories, manage SSH keys and tokens, and edit your profile.</p><a class="btn" href="/login">Log in ▸</a></section>
12+<div class="landing">
13+<p>Welcome to <a href="/">{{.Host}}</a>, a small forge for code worth reading.</p>
14+<p><a class="btn primary" href="/register">Register here ▸</a> or <a class="arrow" href="/login">Log in ▸</a> to get started, or check out some of our featured projects:</p>
15+<h3 class="featured">Featured projects</h3>
16+<div class="featuredgrid">{{range .Data.Featured}}<div class="feature"><h4><a class="owner" href="/~{{.Owner.Name}}">~{{.Owner.Name}}</a>/<a href="/{{.Path}}">{{.Name}}</a></h4>{{if .Description}}<blockquote>{{.Description}}</blockquote>{{end}}{{if .Tags}}<p class="tags">{{range .Tags}}<a href="/projects?tag={{.}}">#{{.}}</a>{{end}}</p>{{end}}</div>{{end}}</div>
17+<div class="more"><a class="btn" href="/projects">More projects ▸</a></div>
1618 </div>
1719 {{end}}
18-<section class="panel wide" id="recent">
20+{{if .User}}<section class="panel wide" id="recent">
1921 <h3>Recent repositories</h3>
2022 <p>{{.Data.Public}} public repositories from {{.Data.Users}} users.</p>
2123 {{template "repolist" .Data.Recent}}
22-</section>
24+</section>{{end}}
2325 {{end}}
2426 {{define "repolist"}}
2527 {{if .}}<div class="events">{{range .}}<div class="event"><h4><a href="/{{.Path}}">{{.Path}}</a>{{if .Private}} <span class="tag">private</span>{{else if .Unlisted}} <span class="tag">unlisted</span>{{end}}{{if .Stars}}<span class="starcount" title="{{.Stars}} stars">☆ {{.Stars}}</span>{{end}}</h4>{{if .Description}}<p>{{.Description}}</p>{{end}}{{if .Tags}}<p class="tags">{{range .Tags}}<a href="/projects?tag={{.}}">#{{.}}</a> {{end}}</p>{{end}}</div>{{end}}</div>{{else}}<p class="muted">Nothing here yet.</p>{{end}}

templates/layout.html modified

@@ -14,10 +14,10 @@
1414 <body{{if .Fluid}} class="fluid"{{end}}>
1515 <header>
1616 <nav class="site">
17-<a class="brand" href="/">{{.Host}}</a>
17+<a class="brand" href="/"><span class="ring"></span>{{.Host}}</a>
1818 <a href="/projects">projects</a>
1919 <span class="spacer"></span>
20-{{if .User}}<span>Logged in as <a href="/~{{.User.Name}}">~{{.User.Name}}</a> — <form method="post" action="/logout"><button>Log out</button></form></span>{{else}}<a href="/login">Log in</a> — <a href="/register">Register</a>{{end}}
20+{{if .User}}<span>Logged in as <a href="/~{{.User.Name}}">~{{.User.Name}}</a> — <form method="post" action="/logout"><button>Log out</button></form></span>{{else}}<span class="auth"><a href="/login">Log in</a> — <a href="/register">Register</a></span>{{end}}
2121 </nav>
2222 {{with .Data}}{{with index . "Settings"}}
2323 <nav class="subtabs">

web.go modified

@@ -231,7 +231,12 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) {
231231 s.fail(w, r, 500, err.Error())
232232 return
233233 }
234- recent = s.byUpdate(recent)[:min(len(recent), 20)]
234+ recent = s.byUpdate(recent)
235+ featured, _ := s.store.FeaturedRepos()
236+ if len(featured) == 0 {
237+ featured = recent[:min(len(recent), 6)]
238+ }
239+ recent = recent[:min(len(recent), 20)]
235240 var repos, shared []Repo
236241 if user != nil {
237242 repos, _ = s.store.ReposByOwner(user.ID, true)
@@ -239,7 +244,7 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) {
239244 repos, shared = s.byUpdate(repos), s.byUpdate(shared)
240245 }
241246 users, public := s.store.Stats()
242- s.render(w, r, "index", page{Title: s.host, User: user, Data: map[string]any{"Repos": repos, "Shared": shared, "Recent": recent, "Users": users, "Public": public}})
247+ s.render(w, r, "index", page{Title: s.host, User: user, Data: map[string]any{"Repos": repos, "Shared": shared, "Recent": recent, "Featured": featured, "Users": users, "Public": public}})
243248 }
244249
245250 func (s *Server) loginForm(w http.ResponseWriter, r *http.Request) {
@@ -1084,7 +1089,7 @@ func (s *Server) tree(w http.ResponseWriter, r *http.Request, repo Repo, spec st
10841089 const maxMedia = 64 << 20
10851090
10861091 var mediaTypes = map[string]string{
1087- ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
1092+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif", ".svg": "image/svg+xml",
10881093 ".mp4": "video/mp4", ".webm": "video/webm", ".ogv": "video/ogg", ".mov": "video/quicktime",
10891094 ".mp3": "audio/mpeg", ".ogg": "audio/ogg", ".wav": "audio/wav", ".flac": "audio/flac",
10901095 }
@@ -1121,6 +1126,7 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request, repo Repo, spec str
11211126 }
11221127 w.Header().Set("Content-Type", kind)
11231128 w.Header().Set("Cache-Control", "public, max-age=300")
1129+ w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox")
11241130 http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(data))
11251131 return
11261132 }