feat(basin): user profiles with avatars and self hosted fonts
9 files changed, +349 −7
- static/fonts/inter.txt added +92 −0
- static/fonts/inter.woff2 added +0 −0
- static/fonts/mono.txt added +93 −0
- static/fonts/mono.woff2 added +0 −0
- static/style.css modified +9 −3
- store.go modified +42 −0
- templates/settings.html modified +15 −0
- templates/user.html modified +8 −1
- web.go modified +90 −3
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')} | |
| 1 | 3 | :root{--bg:#1b1d1e;--fg:#e6e6e3;--muted:#8e9398;--line:#2d3133;--accent:#8fbc8f;--add:#9ccc65;--del:#ef7b6d;--addbg:#1f2b1f;--delbg:#2e1f1f;--code:#151718} |
| 2 | 4 | *{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)} | |
| 4 | 6 | body{margin:0} |
| 5 | 7 | a{color:var(--accent);text-decoration:none} |
| 6 | 8 | 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} | |
| 8 | 10 | pre{background:var(--code);padding:.6rem .8rem;overflow-x:auto;border:1px solid var(--line)} |
| 9 | 11 | header{border-bottom:1px solid var(--line)} |
| 10 | 12 | 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 | ||
| 65 | 67 | form.stack label{display:flex;flex-direction:column;gap:.2rem;color:var(--muted)} |
| 66 | 68 | form.stack label.check{flex-direction:row;align-items:center;gap:.5rem} |
| 67 | 69 | 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} | |
| 69 | 71 | button{font:inherit;background:var(--accent);color:#111;border:0;padding:.35rem .9rem;cursor:pointer;align-self:flex-start} |
| 70 | 72 | button.danger{background:none;color:var(--del);border:1px solid var(--del);padding:.1rem .5rem} |
| 71 | 73 | 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 ( | ||
| 58 | 58 | ); |
| 59 | 59 | ` |
| 60 | 60 | |
| 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 | + | |
| 61 | 77 | var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]{0,38}$`) |
| 62 | 78 | |
| 63 | 79 | var ErrNotFound = errors.New("not found") |
| @@ -108,6 +124,11 @@ func openStore(path string, readonly bool) (*Store, error) { | ||
| 108 | 124 | if _, err := db.Exec(schema); err != nil { |
| 109 | 125 | return nil, err |
| 110 | 126 | } |
| 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 | + } | |
| 111 | 132 | } |
| 112 | 133 | return &Store{db}, nil |
| 113 | 134 | } |
| @@ -396,6 +417,27 @@ func (s *Store) DeleteRepo(id int64) error { | ||
| 396 | 417 | return err |
| 397 | 418 | } |
| 398 | 419 | |
| 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 | + | |
| 399 | 441 | func (s *Store) CreateInvite() (string, error) { |
| 400 | 442 | code := random(12) |
| 401 | 443 | _, err := s.db.Exec(`insert into invites(code) values (?)`, code) |
templates/settings.html modified
| @@ -1,5 +1,20 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | <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}} | |
| 3 | 18 | <h2>ssh keys</h2> |
| 4 | 19 | {{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}} |
| 5 | 20 | <form method="post" action="/settings/keys" class="stack"> |
templates/user.html modified
| @@ -1,6 +1,13 @@ | ||
| 1 | 1 | {{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> | |
| 2 | 6 | <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> | |
| 4 | 11 | {{template "repolist" .Data.Repos}} |
| 5 | 12 | {{end}} |
| 6 | 13 | {{define "repolist"}} |
web.go modified
| @@ -5,6 +5,7 @@ import ( | ||
| 5 | 5 | "embed" |
| 6 | 6 | "fmt" |
| 7 | 7 | "html/template" |
| 8 | + "io" | |
| 8 | 9 | "io/fs" |
| 9 | 10 | "log" |
| 10 | 11 | "net/http" |
| @@ -131,6 +132,9 @@ func (s *Server) handler() http.Handler { | ||
| 131 | 132 | mux.HandleFunc("POST /settings/tokens", s.addToken) |
| 132 | 133 | mux.HandleFunc("POST /settings/tokens/{id}/delete", s.deleteToken) |
| 133 | 134 | 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) | |
| 134 | 138 | mux.HandleFunc("/", s.route) |
| 135 | 139 | cop := http.NewCrossOriginProtection() |
| 136 | 140 | return cop.Handler(mux) |
| @@ -257,7 +261,87 @@ func (s *Server) settings(w http.ResponseWriter, r *http.Request) { | ||
| 257 | 261 | keys, _ := s.store.Keys(u.ID) |
| 258 | 262 | tokens, _ := s.store.Tokens(u.ID) |
| 259 | 263 | 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) | |
| 261 | 345 | } |
| 262 | 346 | |
| 263 | 347 | func (s *Server) addKey(w http.ResponseWriter, r *http.Request) { |
| @@ -339,10 +423,14 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { | ||
| 339 | 423 | s.fail(w, r, 500, err.Error()) |
| 340 | 424 | return |
| 341 | 425 | } |
| 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)}}) | |
| 343 | 427 | return |
| 344 | 428 | } |
| 345 | 429 | name := parts[1] |
| 430 | + if name == "avatar" && len(parts) == 2 { | |
| 431 | + s.serveAvatar(w, r, owner) | |
| 432 | + return | |
| 433 | + } | |
| 346 | 434 | rest := "" |
| 347 | 435 | if len(parts) == 3 { |
| 348 | 436 | rest = parts[2] |
| @@ -684,4 +772,3 @@ func humanSize(n int64) string { | ||
| 684 | 772 | return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20)) |
| 685 | 773 | } |
| 686 | 774 | } |
| 687 | - | |