~gitpocket/gitpocket

1

feat(basin): user profiles with avatars and self hosted fonts

commit
b50e7070549aa7261e261c602aba709380c47b0f browse tree
parent
a7ba5842247eb2aaebd1462924e46678ec05e3a2
author
james <j@cpl0.zip>
signature
none
date
2026-09-20 16:17 UTC

9 files changed, +349 −7

static/fonts/inter.txt added

@@ -0,0 +1,92 @@
1+Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
2+
3+This Font Software is licensed under the SIL Open Font License, Version 1.1.
4+This license is copied below, and is also available with a FAQ at:
5+http://scripts.sil.org/OFL
6+
7+-----------------------------------------------------------
8+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
9+-----------------------------------------------------------
10+
11+PREAMBLE
12+The goals of the Open Font License (OFL) are to stimulate worldwide
13+development of collaborative font projects, to support the font creation
14+efforts of academic and linguistic communities, and to provide a free and
15+open framework in which fonts may be shared and improved in partnership
16+with others.
17+
18+The OFL allows the licensed fonts to be used, studied, modified and
19+redistributed freely as long as they are not sold by themselves. The
20+fonts, including any derivative works, can be bundled, embedded,
21+redistributed and/or sold with any software provided that any reserved
22+names are not used by derivative works. The fonts and derivatives,
23+however, cannot be released under any other type of license. The
24+requirement for fonts to remain under this license does not apply
25+to any document created using the fonts or their derivatives.
26+
27+DEFINITIONS
28+"Font Software" refers to the set of files released by the Copyright
29+Holder(s) under this license and clearly marked as such. This may
30+include source files, build scripts and documentation.
31+
32+"Reserved Font Name" refers to any names specified as such after the
33+copyright statement(s).
34+
35+"Original Version" refers to the collection of Font Software components as
36+distributed by the Copyright Holder(s).
37+
38+"Modified Version" refers to any derivative made by adding to, deleting,
39+or substituting -- in part or in whole -- any of the components of the
40+Original Version, by changing formats or by porting the Font Software to a
41+new environment.
42+
43+"Author" refers to any designer, engineer, programmer, technical
44+writer or other person who contributed to the Font Software.
45+
46+PERMISSION AND CONDITIONS
47+Permission is hereby granted, free of charge, to any person obtaining
48+a copy of the Font Software, to use, study, copy, merge, embed, modify,
49+redistribute, and sell modified and unmodified copies of the Font
50+Software, subject to the following conditions:
51+
52+1) Neither the Font Software nor any of its individual components,
53+in Original or Modified Versions, may be sold by itself.
54+
55+2) Original or Modified Versions of the Font Software may be bundled,
56+redistributed and/or sold with any software, provided that each copy
57+contains the above copyright notice and this license. These can be
58+included either as stand-alone text files, human-readable headers or
59+in the appropriate machine-readable metadata fields within text or
60+binary files as long as those fields can be easily viewed by the user.
61+
62+3) No Modified Version of the Font Software may use the Reserved Font
63+Name(s) unless explicit written permission is granted by the corresponding
64+Copyright Holder. This restriction only applies to the primary font name as
65+presented to the users.
66+
67+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
68+Software shall not be used to promote, endorse or advertise any
69+Modified Version, except to acknowledge the contribution(s) of the
70+Copyright Holder(s) and the Author(s) or with their explicit written
71+permission.
72+
73+5) The Font Software, modified or unmodified, in part or in whole,
74+must be distributed entirely under this license, and must not be
75+distributed under any other license. The requirement for fonts to
76+remain under this license does not apply to any document created
77+using the Font Software.
78+
79+TERMINATION
80+This license becomes null and void if any of the above conditions are
81+not met.
82+
83+DISCLAIMER
84+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
85+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
86+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
87+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
88+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
89+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
90+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
91+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
92+OTHER DEALINGS IN THE FONT SOFTWARE.

static/fonts/inter.woff2 added

Binary file.

static/fonts/mono.txt added

@@ -0,0 +1,93 @@
1+Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
2+
3+This Font Software is licensed under the SIL Open Font License, Version 1.1.
4+This license is copied below, and is also available with a FAQ at:
5+https://scripts.sil.org/OFL
6+
7+
8+-----------------------------------------------------------
9+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10+-----------------------------------------------------------
11+
12+PREAMBLE
13+The goals of the Open Font License (OFL) are to stimulate worldwide
14+development of collaborative font projects, to support the font creation
15+efforts of academic and linguistic communities, and to provide a free and
16+open framework in which fonts may be shared and improved in partnership
17+with others.
18+
19+The OFL allows the licensed fonts to be used, studied, modified and
20+redistributed freely as long as they are not sold by themselves. The
21+fonts, including any derivative works, can be bundled, embedded,
22+redistributed and/or sold with any software provided that any reserved
23+names are not used by derivative works. The fonts and derivatives,
24+however, cannot be released under any other type of license. The
25+requirement for fonts to remain under this license does not apply
26+to any document created using the fonts or their derivatives.
27+
28+DEFINITIONS
29+"Font Software" refers to the set of files released by the Copyright
30+Holder(s) under this license and clearly marked as such. This may
31+include source files, build scripts and documentation.
32+
33+"Reserved Font Name" refers to any names specified as such after the
34+copyright statement(s).
35+
36+"Original Version" refers to the collection of Font Software components as
37+distributed by the Copyright Holder(s).
38+
39+"Modified Version" refers to any derivative made by adding to, deleting,
40+or substituting -- in part or in whole -- any of the components of the
41+Original Version, by changing formats or by porting the Font Software to a
42+new environment.
43+
44+"Author" refers to any designer, engineer, programmer, technical
45+writer or other person who contributed to the Font Software.
46+
47+PERMISSION & CONDITIONS
48+Permission is hereby granted, free of charge, to any person obtaining
49+a copy of the Font Software, to use, study, copy, merge, embed, modify,
50+redistribute, and sell modified and unmodified copies of the Font
51+Software, subject to the following conditions:
52+
53+1) Neither the Font Software nor any of its individual components,
54+in Original or Modified Versions, may be sold by itself.
55+
56+2) Original or Modified Versions of the Font Software may be bundled,
57+redistributed and/or sold with any software, provided that each copy
58+contains the above copyright notice and this license. These can be
59+included either as stand-alone text files, human-readable headers or
60+in the appropriate machine-readable metadata fields within text or
61+binary files as long as those fields can be easily viewed by the user.
62+
63+3) No Modified Version of the Font Software may use the Reserved Font
64+Name(s) unless explicit written permission is granted by the corresponding
65+Copyright Holder. This restriction only applies to the primary font name as
66+presented to the users.
67+
68+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69+Software shall not be used to promote, endorse or advertise any
70+Modified Version, except to acknowledge the contribution(s) of the
71+Copyright Holder(s) and the Author(s) or with their explicit written
72+permission.
73+
74+5) The Font Software, modified or unmodified, in part or in whole,
75+must be distributed entirely under this license, and must not be
76+distributed under any other license. The requirement for fonts to
77+remain under this license does not apply to any document created
78+using the Font Software.
79+
80+TERMINATION
81+This license becomes null and void if any of the above conditions are
82+not met.
83+
84+DISCLAIMER
85+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93+OTHER DEALINGS IN THE FONT SOFTWARE.

static/fonts/mono.woff2 added

Binary file.

static/style.css modified

@@ -1,10 +1,12 @@
1+@font-face{font-family:Inter;font-style:normal;font-weight:100 900;font-display:swap;src:url(/static/fonts/inter.woff2) format('woff2')}
2+@font-face{font-family:Mono;font-style:normal;font-weight:400;font-display:swap;src:url(/static/fonts/mono.woff2) format('woff2')}
13 :root{--bg:#1b1d1e;--fg:#e6e6e3;--muted:#8e9398;--line:#2d3133;--accent:#8fbc8f;--add:#9ccc65;--del:#ef7b6d;--addbg:#1f2b1f;--delbg:#2e1f1f;--code:#151718}
24 *{box-sizing:border-box}
3-html{font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;background:var(--bg);color:var(--fg)}
5+html{font:15px/1.5 Inter,system-ui,sans-serif;font-feature-settings:"cv05","ss03";background:var(--bg);color:var(--fg)}
46 body{margin:0}
57 a{color:var(--accent);text-decoration:none}
68 a:hover{text-decoration:underline}
7-code,pre,.hash,.tree,.blob,.hunk,.refs .hash,.mode{font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace;font-size:13px}
9+code,pre,.hash,.tree,.blob,.hunk,.refs .hash,.mode{font-family:Mono,ui-monospace,Menlo,Consolas,monospace;font-size:13px}
810 pre{background:var(--code);padding:.6rem .8rem;overflow-x:auto;border:1px solid var(--line)}
911 header{border-bottom:1px solid var(--line)}
1012 nav{max-width:1000px;margin:0 auto;padding:.5rem 1rem;display:flex;gap:1.2rem;align-items:center;flex-wrap:wrap}
@@ -65,7 +67,11 @@ form.stack{display:flex;flex-direction:column;gap:.6rem;max-width:32rem;margin-b
6567 form.stack label{display:flex;flex-direction:column;gap:.2rem;color:var(--muted)}
6668 form.stack label.check{flex-direction:row;align-items:center;gap:.5rem}
6769 input,textarea{font:inherit;background:var(--code);color:var(--fg);border:1px solid var(--line);padding:.35rem .5rem}
68-textarea{font-family:ui-monospace,monospace;font-size:13px}
70+textarea{font-family:Mono,ui-monospace,monospace;font-size:13px}
6971 button{font:inherit;background:var(--accent);color:#111;border:0;padding:.35rem .9rem;cursor:pointer;align-self:flex-start}
7072 button.danger{background:none;color:var(--del);border:1px solid var(--del);padding:.1rem .5rem}
7173 pre.body{white-space:pre-wrap}
74+.profile{display:flex;gap:1.2rem;align-items:flex-start;margin-bottom:1rem}
75+.avatar{width:96px;height:96px;object-fit:cover;border:1px solid var(--line);background:var(--code)}
76+.bio{white-space:pre-wrap;margin:.2rem 0 .4rem;max-width:40rem}
77+input[type=file]{border:0;padding:0}

store.go modified

@@ -58,6 +58,22 @@ create table if not exists invites (
5858 );
5959 `
6060
61+var migrations = []string{
62+ `alter table users add column bio text not null default ''`,
63+ `alter table users add column location text not null default ''`,
64+ `alter table users add column workplace text not null default ''`,
65+ `alter table users add column website text not null default ''`,
66+ `alter table users add column avatar integer not null default 0`,
67+}
68+
69+type Profile struct {
70+ Bio string
71+ Location string
72+ Workplace string
73+ Website string
74+ Avatar bool
75+}
76+
6177 var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]{0,38}$`)
6278
6379 var ErrNotFound = errors.New("not found")
@@ -108,6 +124,11 @@ func openStore(path string, readonly bool) (*Store, error) {
108124 if _, err := db.Exec(schema); err != nil {
109125 return nil, err
110126 }
127+ for _, m := range migrations {
128+ if _, err := db.Exec(m); err != nil && !strings.Contains(err.Error(), "duplicate column") {
129+ return nil, err
130+ }
131+ }
111132 }
112133 return &Store{db}, nil
113134 }
@@ -396,6 +417,27 @@ func (s *Store) DeleteRepo(id int64) error {
396417 return err
397418 }
398419
420+func (s *Store) Profile(user int64) Profile {
421+ var p Profile
422+ var avatar int
423+ s.db.QueryRow(`select bio, location, workplace, website, avatar from users where id = ?`, user).Scan(&p.Bio, &p.Location, &p.Workplace, &p.Website, &avatar)
424+ p.Avatar = avatar != 0
425+ return p
426+}
427+
428+func (s *Store) UpdateProfile(user int64, p Profile) error {
429+ if p.Website != "" && !strings.HasPrefix(p.Website, "https://") && !strings.HasPrefix(p.Website, "http://") {
430+ p.Website = "https://" + p.Website
431+ }
432+ _, err := s.db.Exec(`update users set bio = ?, location = ?, workplace = ?, website = ? where id = ?`, p.Bio, p.Location, p.Workplace, p.Website, user)
433+ return err
434+}
435+
436+func (s *Store) SetAvatar(user int64, has bool) error {
437+ _, err := s.db.Exec(`update users set avatar = ? where id = ?`, has, user)
438+ return err
439+}
440+
399441 func (s *Store) CreateInvite() (string, error) {
400442 code := random(12)
401443 _, err := s.db.Exec(`insert into invites(code) values (?)`, code)

templates/settings.html modified

@@ -1,5 +1,20 @@
11 {{define "content"}}
22 <h1>settings</h1>
3+<h2>profile</h2>
4+{{$p := .Data.Profile}}
5+<form method="post" action="/settings/profile" class="stack">
6+<label>bio <textarea name="bio" rows="3" maxlength="1000">{{$p.Bio}}</textarea></label>
7+<label>location <input name="location" value="{{$p.Location}}" maxlength="100"></label>
8+<label>workplace <input name="workplace" value="{{$p.Workplace}}" maxlength="100"></label>
9+<label>website <input name="website" value="{{$p.Website}}" maxlength="200" placeholder="https://"></label>
10+<button>save profile</button>
11+</form>
12+<form method="post" action="/settings/avatar" enctype="multipart/form-data" class="stack">
13+{{if $p.Avatar}}<img class="avatar" src="/~{{.User.Name}}/avatar" alt="">{{end}}
14+<label>avatar <input type="file" name="avatar" accept="image/png,image/jpeg,image/gif,image/webp" required></label>
15+<button>upload avatar</button>
16+</form>
17+{{if $p.Avatar}}<form method="post" action="/settings/avatar/delete"><button class="danger">remove avatar</button></form>{{end}}
318 <h2>ssh keys</h2>
419 {{if .Data.Keys}}<table class="keys">{{range .Data.Keys}}<tr><td>{{.Title}}</td><td><code>{{.Fingerprint}}</code></td><td class="when">{{ago .Created}}</td><td><form method="post" action="/settings/keys/{{.ID}}/delete"><button class="danger">remove</button></form></td></tr>{{end}}</table>{{end}}
520 <form method="post" action="/settings/keys" class="stack">

templates/user.html modified

@@ -1,6 +1,13 @@
11 {{define "content"}}
2+{{$p := .Data.Profile}}
3+<div class="profile">
4+{{if $p.Avatar}}<img class="avatar" src="/~{{.Data.Owner.Name}}/avatar" alt="">{{end}}
5+<div>
26 <h1>~{{.Data.Owner.Name}}</h1>
3-<p class="muted">joined {{ago .Data.Owner.Created}}</p>
7+{{if $p.Bio}}<p class="bio">{{$p.Bio}}</p>{{end}}
8+<p class="muted">{{if $p.Location}}{{$p.Location}} · {{end}}{{if $p.Workplace}}{{$p.Workplace}} · {{end}}{{if $p.Website}}<a href="{{$p.Website}}" rel="nofollow me">{{$p.Website}}</a> · {{end}}joined {{ago .Data.Owner.Created}}</p>
9+</div>
10+</div>
411 {{template "repolist" .Data.Repos}}
512 {{end}}
613 {{define "repolist"}}

web.go modified

@@ -5,6 +5,7 @@ import (
55 "embed"
66 "fmt"
77 "html/template"
8+ "io"
89 "io/fs"
910 "log"
1011 "net/http"
@@ -131,6 +132,9 @@ func (s *Server) handler() http.Handler {
131132 mux.HandleFunc("POST /settings/tokens", s.addToken)
132133 mux.HandleFunc("POST /settings/tokens/{id}/delete", s.deleteToken)
133134 mux.HandleFunc("POST /settings/password", s.password)
135+ mux.HandleFunc("POST /settings/profile", s.profile)
136+ mux.HandleFunc("POST /settings/avatar", s.avatar)
137+ mux.HandleFunc("POST /settings/avatar/delete", s.deleteAvatar)
134138 mux.HandleFunc("/", s.route)
135139 cop := http.NewCrossOriginProtection()
136140 return cop.Handler(mux)
@@ -257,7 +261,87 @@ func (s *Server) settings(w http.ResponseWriter, r *http.Request) {
257261 keys, _ := s.store.Keys(u.ID)
258262 tokens, _ := s.store.Tokens(u.ID)
259263 s.render(w, r, "settings", page{Title: "settings", User: u, Error: r.URL.Query().Get("error"),
260- Data: map[string]any{"Keys": keys, "Tokens": tokens, "NewToken": r.URL.Query().Get("token")}})
264+ Data: map[string]any{"Keys": keys, "Tokens": tokens, "NewToken": r.URL.Query().Get("token"), "Profile": s.store.Profile(u.ID)}})
265+}
266+
267+func (s *Server) profile(w http.ResponseWriter, r *http.Request) {
268+ u := s.require(w, r)
269+ if u == nil {
270+ return
271+ }
272+ field := func(name string, max int) string {
273+ v := strings.TrimSpace(r.FormValue(name))
274+ if len(v) > max {
275+ v = v[:max]
276+ }
277+ return v
278+ }
279+ p := Profile{Bio: field("bio", 1000), Location: field("location", 100), Workplace: field("workplace", 100), Website: field("website", 200)}
280+ if err := s.store.UpdateProfile(u.ID, p); err != nil {
281+ http.Redirect(w, r, "/settings?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
282+ return
283+ }
284+ http.Redirect(w, r, "/~"+u.Name, http.StatusSeeOther)
285+}
286+
287+func (s *Server) avatarPath(id int64) string {
288+ return filepath.Join(s.root, "avatars", strconv.FormatInt(id, 10))
289+}
290+
291+func (s *Server) avatar(w http.ResponseWriter, r *http.Request) {
292+ u := s.require(w, r)
293+ if u == nil {
294+ return
295+ }
296+ r.Body = http.MaxBytesReader(w, r.Body, 2<<20)
297+ f, _, err := r.FormFile("avatar")
298+ if err != nil {
299+ http.Redirect(w, r, "/settings?error=choose+an+image+under+2+MiB", http.StatusSeeOther)
300+ return
301+ }
302+ defer f.Close()
303+ data, err := io.ReadAll(f)
304+ if err != nil {
305+ http.Redirect(w, r, "/settings?error=choose+an+image+under+2+MiB", http.StatusSeeOther)
306+ return
307+ }
308+ switch http.DetectContentType(data) {
309+ case "image/png", "image/jpeg", "image/gif", "image/webp":
310+ default:
311+ http.Redirect(w, r, "/settings?error=avatar+must+be+png%2C+jpeg%2C+gif+or+webp", http.StatusSeeOther)
312+ return
313+ }
314+ if err := os.MkdirAll(filepath.Join(s.root, "avatars"), 0o755); err == nil {
315+ err = os.WriteFile(s.avatarPath(u.ID), data, 0o644)
316+ }
317+ if err != nil {
318+ http.Redirect(w, r, "/settings?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
319+ return
320+ }
321+ s.store.SetAvatar(u.ID, true)
322+ http.Redirect(w, r, "/~"+u.Name, http.StatusSeeOther)
323+}
324+
325+func (s *Server) deleteAvatar(w http.ResponseWriter, r *http.Request) {
326+ u := s.require(w, r)
327+ if u == nil {
328+ return
329+ }
330+ os.Remove(s.avatarPath(u.ID))
331+ s.store.SetAvatar(u.ID, false)
332+ http.Redirect(w, r, "/settings", http.StatusSeeOther)
333+}
334+
335+func (s *Server) serveAvatar(w http.ResponseWriter, r *http.Request, owner User) {
336+ data, err := os.ReadFile(s.avatarPath(owner.ID))
337+ if err != nil {
338+ http.NotFound(w, r)
339+ return
340+ }
341+ w.Header().Set("Content-Type", http.DetectContentType(data))
342+ w.Header().Set("X-Content-Type-Options", "nosniff")
343+ w.Header().Set("Cache-Control", "public, max-age=300")
344+ w.Write(data)
261345 }
262346
263347 func (s *Server) addKey(w http.ResponseWriter, r *http.Request) {
@@ -339,10 +423,14 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
339423 s.fail(w, r, 500, err.Error())
340424 return
341425 }
342- s.render(w, r, "user", page{Title: "~" + owner.Name, User: user, Data: map[string]any{"Owner": owner, "Repos": repos}})
426+ s.render(w, r, "user", page{Title: "~" + owner.Name, User: user, Data: map[string]any{"Owner": owner, "Repos": repos, "Profile": s.store.Profile(owner.ID)}})
343427 return
344428 }
345429 name := parts[1]
430+ if name == "avatar" && len(parts) == 2 {
431+ s.serveAvatar(w, r, owner)
432+ return
433+ }
346434 rest := ""
347435 if len(parts) == 3 {
348436 rest = parts[2]
@@ -684,4 +772,3 @@ func humanSize(n int64) string {
684772 return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20))
685773 }
686774 }
687-