feat(web): featured landing page and backups
8 files changed, +151 −15
- README.md modified +9 −0
- mail.go modified +40 −3
- main.go modified +39 −1
- static/style.css modified +19 −0
- store.go modified +25 −0
- templates/index.html modified +8 −6
- templates/layout.html modified +2 −2
- web.go modified +9 −3
README.md modified
| @@ -68,6 +68,15 @@ sudo -u git basin -root /srv/basin invite | ||
| 68 | 68 | |
| 69 | 69 | 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`. |
| 70 | 70 | |
| 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 | + | |
| 71 | 80 | ## Use |
| 72 | 81 | |
| 73 | 82 | ``` |
mail.go modified
| @@ -1,12 +1,15 @@ | ||
| 1 | 1 | package main |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "bytes" | |
| 4 | 5 | "crypto/tls" |
| 5 | 6 | "encoding/json" |
| 6 | 7 | "errors" |
| 7 | 8 | "fmt" |
| 9 | + "io" | |
| 8 | 10 | "log" |
| 9 | 11 | "net" |
| 12 | + "net/http" | |
| 10 | 13 | "net/smtp" |
| 11 | 14 | "os" |
| 12 | 15 | "path/filepath" |
| @@ -22,6 +25,8 @@ type Mailer struct { | ||
| 22 | 25 | From string `json:"from"` |
| 23 | 26 | StartTLS bool `json:"starttls"` |
| 24 | 27 | Insecure bool `json:"insecure"` |
| 28 | + API string `json:"api"` | |
| 29 | + Key string `json:"key"` | |
| 25 | 30 | } |
| 26 | 31 | |
| 27 | 32 | func loadMailer(root string) *Mailer { |
| @@ -30,7 +35,7 @@ func loadMailer(root string) *Mailer { | ||
| 30 | 35 | return nil |
| 31 | 36 | } |
| 32 | 37 | 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 == "" { | |
| 34 | 39 | return nil |
| 35 | 40 | } |
| 36 | 41 | if m.Port == 0 { |
| @@ -43,6 +48,9 @@ func (m *Mailer) Send(to, subject, body string) error { | ||
| 43 | 48 | if m == nil { |
| 44 | 49 | return errors.New("mail is not configured") |
| 45 | 50 | } |
| 51 | + if m.API != "" { | |
| 52 | + return m.post(to, subject, body) | |
| 53 | + } | |
| 46 | 54 | addr := net.JoinHostPort(m.Host, fmt.Sprint(m.Port)) |
| 47 | 55 | tlsConfig := &tls.Config{ServerName: m.Host, InsecureSkipVerify: m.Insecure} |
| 48 | 56 | var client *smtp.Client |
| @@ -80,8 +88,8 @@ func (m *Mailer) Send(to, subject, body string) error { | ||
| 80 | 88 | if err != nil { |
| 81 | 89 | return err |
| 82 | 90 | } |
| 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) | |
| 85 | 93 | if _, err := w.Write([]byte(msg)); err != nil { |
| 86 | 94 | return err |
| 87 | 95 | } |
| @@ -101,3 +109,32 @@ func (s *Server) notify(to, subject, body string) { | ||
| 101 | 109 | } |
| 102 | 110 | }() |
| 103 | 111 | } |
| 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 ( | ||
| 8 | 8 | "os" |
| 9 | 9 | "path/filepath" |
| 10 | 10 | "strconv" |
| 11 | + "strings" | |
| 11 | 12 | "time" |
| 12 | 13 | ) |
| 13 | 14 | |
| @@ -24,7 +25,10 @@ func main() { | ||
| 24 | 25 | user name create a user, password read from stdin |
| 25 | 26 | passwd name set a user's password, read from stdin |
| 26 | 27 | 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`) | |
| 28 | 32 | } |
| 29 | 33 | flag.Parse() |
| 30 | 34 | args := flag.Args() |
| @@ -95,6 +99,40 @@ func main() { | ||
| 95 | 99 | log.Fatal(err) |
| 96 | 100 | } |
| 97 | 101 | 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 | + } | |
| 98 | 136 | case "ban", "unban": |
| 99 | 137 | if len(args) != 2 { |
| 100 | 138 | 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} | ||
| 291 | 291 | .inlineform{display:inline} |
| 292 | 292 | .linkbutton{background:none;border:0;padding:0;font:inherit;color:var(--muted);cursor:pointer} |
| 293 | 293 | .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{ | ||
| 75 | 75 | `alter table users add column totp text not null default ''`, |
| 76 | 76 | `alter table users add column recovery text not null default ''`, |
| 77 | 77 | `alter table pulls add column draft integer not null default 0`, |
| 78 | + `alter table repos add column featured integer not null default 0`, | |
| 78 | 79 | } |
| 79 | 80 | |
| 80 | 81 | const extraSchema = ` |
| @@ -570,6 +571,30 @@ func scanRepos(rows *sql.Rows) ([]Repo, error) { | ||
| 570 | 571 | |
| 571 | 572 | 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` |
| 572 | 573 | |
| 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 | + | |
| 573 | 598 | func (s *Store) RepoByID(id int64) (Repo, error) { |
| 574 | 599 | rows, err := s.db.Query(`select `+repoColumns+` where r.id = ?`, id) |
| 575 | 600 | if err != nil { |
templates/index.html modified
| @@ -9,17 +9,19 @@ | ||
| 9 | 9 | <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> |
| 10 | 10 | </div> |
| 11 | 11 | {{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> | |
| 16 | 18 | </div> |
| 17 | 19 | {{end}} |
| 18 | -<section class="panel wide" id="recent"> | |
| 20 | +{{if .User}}<section class="panel wide" id="recent"> | |
| 19 | 21 | <h3>Recent repositories</h3> |
| 20 | 22 | <p>{{.Data.Public}} public repositories from {{.Data.Users}} users.</p> |
| 21 | 23 | {{template "repolist" .Data.Recent}} |
| 22 | -</section> | |
| 24 | +</section>{{end}} | |
| 23 | 25 | {{end}} |
| 24 | 26 | {{define "repolist"}} |
| 25 | 27 | {{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 @@ | ||
| 14 | 14 | <body{{if .Fluid}} class="fluid"{{end}}> |
| 15 | 15 | <header> |
| 16 | 16 | <nav class="site"> |
| 17 | -<a class="brand" href="/">{{.Host}}</a> | |
| 17 | +<a class="brand" href="/"><span class="ring"></span>{{.Host}}</a> | |
| 18 | 18 | <a href="/projects">projects</a> |
| 19 | 19 | <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}} | |
| 21 | 21 | </nav> |
| 22 | 22 | {{with .Data}}{{with index . "Settings"}} |
| 23 | 23 | <nav class="subtabs"> |
web.go modified
| @@ -231,7 +231,12 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) { | ||
| 231 | 231 | s.fail(w, r, 500, err.Error()) |
| 232 | 232 | return |
| 233 | 233 | } |
| 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)] | |
| 235 | 240 | var repos, shared []Repo |
| 236 | 241 | if user != nil { |
| 237 | 242 | repos, _ = s.store.ReposByOwner(user.ID, true) |
| @@ -239,7 +244,7 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) { | ||
| 239 | 244 | repos, shared = s.byUpdate(repos), s.byUpdate(shared) |
| 240 | 245 | } |
| 241 | 246 | 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}}) | |
| 243 | 248 | } |
| 244 | 249 | |
| 245 | 250 | 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 | ||
| 1084 | 1089 | const maxMedia = 64 << 20 |
| 1085 | 1090 | |
| 1086 | 1091 | 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", | |
| 1088 | 1093 | ".mp4": "video/mp4", ".webm": "video/webm", ".ogv": "video/ogg", ".mov": "video/quicktime", |
| 1089 | 1094 | ".mp3": "audio/mpeg", ".ogg": "audio/ogg", ".wav": "audio/wav", ".flac": "audio/flac", |
| 1090 | 1095 | } |
| @@ -1121,6 +1126,7 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request, repo Repo, spec str | ||
| 1121 | 1126 | } |
| 1122 | 1127 | w.Header().Set("Content-Type", kind) |
| 1123 | 1128 | 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") | |
| 1124 | 1130 | http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(data)) |
| 1125 | 1131 | return |
| 1126 | 1132 | } |