From 30826f367126c9a50acc9b1373b145d88a08dad0 Mon Sep 17 00:00:00 2001 From: Thayol Date: Sun, 13 Sep 2026 11:28:17 +0200 Subject: [PATCH] Add files page --- internal/server/admin.go | 83 +++++++--- internal/server/delete.go | 10 +- internal/server/login.go | 2 +- internal/server/server.go | 4 +- internal/server/server_test.go | 183 ++++++++++++++++++----- web/templates/{admin.html => files.html} | 44 +++++- web/templates/layout.html | 2 +- 7 files changed, 247 insertions(+), 81 deletions(-) rename web/templates/{admin.html => files.html} (61%) diff --git a/internal/server/admin.go b/internal/server/admin.go index e65033c..9e71ca1 100644 --- a/internal/server/admin.go +++ b/internal/server/admin.go @@ -10,16 +10,24 @@ import ( "uncensored-send/internal/config" ) -// adminPage is the one view that shows every object, regardless of who -// uploaded it. It exists because "admin" otherwise only means "may delete -// anyone's file", with no way to see whose files those are. -type adminPage struct { +// filesPage lists uploads. One page serves two readers: an admin sees every +// object plus what the server as a whole is holding, and a token holder sees +// the files uploaded under their own token, which is otherwise information +// they have no way to get back. +type filesPage struct { page Objects []adminObject - Tokens []adminToken Sort string + Count int + Listed string // bytes held by the files actually listed - Count int + // NeedsToken replaces the listing with an invitation to log in. Uploads + // are recorded against the token that made them, so there is nothing to + // show someone who has not presented one. + NeedsToken bool + + // The rest is the server's own state, and only an admin sees it. + Tokens []adminToken Total string Quota string // empty when there is no quota QuotaPct int @@ -79,17 +87,19 @@ func compareExpiry(a, b *time.Time) int { return a.Compare(*b) } -func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleFiles(w http.ResponseWriter, r *http.Request) { lim, err := s.limitsFor(r, credential(r)) - switch { - case err != nil: + if err != nil { s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.") return - case lim.Anonymous(): - s.fail(w, r, http.StatusUnauthorized, "This page needs an admin token.") - return - case !lim.Admin: - s.fail(w, r, http.StatusForbidden, "That token is not an admin token.") + } + if lim.Anonymous() { + // Not an error: the page exists, it just has nothing to say without a + // token. An anonymous upload is not recorded against anyone. + s.render(w, http.StatusOK, "files.html", filesPage{ + page: s.page(r, "Files", false), + NeedsToken: true, + }) return } @@ -100,16 +110,23 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) { now := s.now() objects := make([]adminObject, 0, s.store.Count()) - anonymous := 0 + anonymous, listed := 0, int64(0) for _, m := range s.store.List() { // Expired objects are logically gone even if the sweeper has not yet // reached them, so they are not listed as though they were still here. if m.Expired(now) { continue } + // A token holder sees their own uploads and nothing else. Anonymous + // files belong to no token, so they stay with the admins, which is + // exactly who may delete them. + if !lim.Admin && m.Owner != lim.Name { + continue + } if m.Owner == "" { anonymous++ } + listed += m.Size objects = append(objects, adminObject{ ID: m.ID, Filename: m.Filename, @@ -127,15 +144,33 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) { } slices.SortStableFunc(objects, adminSorts[sortBy]) - data := adminPage{ - page: s.widePage(r, "Administration"), - Objects: objects, - Tokens: s.adminTokens(), - Sort: sortBy, - Count: len(objects), - Total: config.FormatBytes(s.store.Total()), - Anonymous: anonymous, + title := "Your files" + if lim.Admin { + title = "All files" } + // s.page resolves the session cookie, which is the right thing for the + // header but not for this body: the page has to describe the credential it + // was actually read with, or an admin presenting a bearer token is shown a + // plain user's view of a listing that was built for an admin. + head := s.widePage(r, title) + head.User, head.Admin = lim.Name, lim.Admin + + data := filesPage{ + page: head, + Objects: objects, + Sort: sortBy, + Count: len(objects), + Listed: config.FormatBytes(listed), + } + if !lim.Admin { + s.render(w, http.StatusOK, "files.html", data) + return + } + + // Everything below is the server's own state rather than anyone's files. + data.Tokens = s.adminTokens() + data.Total = config.FormatBytes(s.store.Total()) + data.Anonymous = anonymous if s.cfg.MaxTotalBytes != config.Unlimited { data.Quota = config.FormatSize(s.cfg.MaxTotalBytes) data.QuotaPct = int(min(100, s.store.Total()*100/max(1, s.cfg.MaxTotalBytes))) @@ -143,7 +178,7 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) { if free, ok := freeBytes(s.store.DataDir()); ok { data.FreeDisk = config.FormatBytes(free) } - s.render(w, http.StatusOK, "admin.html", data) + s.render(w, http.StatusOK, "files.html", data) } // adminTokens describes the configured credentials. Only names and limits are diff --git a/internal/server/delete.go b/internal/server/delete.go index 5f97763..4bec009 100644 --- a/internal/server/delete.go +++ b/internal/server/delete.go @@ -58,11 +58,11 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "id": id}) return } - // Deleting from the administration table goes back to it. The destination - // is built from configuration, never from the request, so this cannot be - // turned into an open redirect. - if from == "admin" { - http.Redirect(w, r, s.cfg.BasePath+"admin", http.StatusSeeOther) + // Deleting from the listing goes back to it. The destination is built from + // configuration, never from the request, so this cannot be turned into an + // open redirect. + if from == "files" { + http.Redirect(w, r, s.cfg.BasePath+"files", http.StatusSeeOther) return } s.render(w, http.StatusOK, "error.html", errorPage{ diff --git a/internal/server/login.go b/internal/server/login.go index 254782d..cf91076 100644 --- a/internal/server/login.go +++ b/internal/server/login.go @@ -25,7 +25,7 @@ type loginPage struct { // it to known page names means the parameter can never name somewhere else. var loginDestinations = map[string]string{ "": "", - "admin": "admin", + "files": "files", } func destination(next string) string { diff --git a/internal/server/server.go b/internal/server/server.go index c151cb9..550cee5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -68,7 +68,7 @@ func (s *Server) routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /{$}", s.handleIndex) mux.HandleFunc("POST /upload", s.handleUpload) - mux.HandleFunc("GET /admin", s.handleAdmin) + mux.HandleFunc("GET /files", s.handleFiles) mux.HandleFunc("GET /d/{id}", s.handleDownload) mux.HandleFunc("GET /i/{id}", s.handleInfo) mux.HandleFunc("POST /d/{id}/delete", s.handleDelete) @@ -199,7 +199,7 @@ func bearer(r *http.Request) string { // --- rendering ----------------------------------------------------------- var pageNames = []string{"index.html", "result.html", "info.html", "error.html", - "admin.html", "login.html"} + "files.html", "login.html"} // parsePages pairs each page with the shared layout. They cannot all be parsed // into one template set because every page defines "content". diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 4ddbf03..b700f35 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -799,7 +799,7 @@ func TestFailedLoginsAreThrottled(t *testing.T) { func TestLoginRedirectIsAllowlisted(t *testing.T) { h := newHarness(t, nil) for _, c := range []struct{ next, want string }{ - {"admin", "/admin"}, + {"files", "/files"}, {"", "/"}, {"https://evil.example.com", "/"}, {"//evil.example.com", "/"}, @@ -1198,33 +1198,135 @@ func (h *harness) get(t *testing.T, path, token string) *http.Response { return resp } -// The admin page shows every stored file, so who may open it is the whole -// security story for this feature. -func TestAdminPageAccessControl(t *testing.T) { +// The listing is open to anyone with a token; what changes is its contents. +// Only a credential that does not resolve is turned away. +func TestFilesPageAccess(t *testing.T) { h := newHarness(t, nil) - cases := []struct { + for _, c := range []struct { who string token string want int }{ - {"anonymous", "", http.StatusUnauthorized}, + {"anonymous", "", http.StatusOK}, {"an unknown token", "not-a-token", http.StatusUnauthorized}, - {"a non-admin token", h.token, http.StatusForbidden}, + {"a plain token", h.token, http.StatusOK}, {"an admin token", h.admin, http.StatusOK}, - } - for _, c := range cases { - resp := h.get(t, "/admin", c.token) + } { + resp := h.get(t, "/files", c.token) resp.Body.Close() if resp.StatusCode != c.want { - t.Errorf("GET /admin as %s => %s, want %d", c.who, resp.Status, c.want) + t.Errorf("GET /files as %s => %s, want %d", c.who, resp.Status, c.want) } } } -// The cookie is the credential a browser actually uses for this page. -func TestAdminPageAcceptsTheSession(t *testing.T) { +// The whole point of the page for a token holder: their own uploads come back, +// and nobody else's do. Without this there is no way to find a file again once +// the link has been lost. +func TestFilesPageListsOnlyYourOwnUploads(t *testing.T) { h := newHarness(t, nil) - req, _ := http.NewRequest("GET", h.ts.URL+"/admin", nil) + + h.upload(t, []byte("mine"), map[string]string{ + "Authorization": "Bearer " + h.token, + "Content-Disposition": `attachment; filename="mine.bin"`}).Body.Close() + h.upload(t, []byte("theirs"), map[string]string{ + "Authorization": "Bearer " + h.admin, + "Content-Disposition": `attachment; filename="theirs.bin"`}).Body.Close() + h.upload(t, []byte("nobodys"), map[string]string{ + "Content-Disposition": `attachment; filename="nobodys.bin"`}).Body.Close() + + resp := h.get(t, "/files", h.token) + raw, _ := io.ReadAll(resp.Body) + resp.Body.Close() + page := string(raw) + + if !strings.Contains(page, "mine.bin") { + t.Error("a token holder cannot see their own upload") + } + for _, hidden := range []string{"theirs.bin", "nobodys.bin"} { + if strings.Contains(page, hidden) { + t.Errorf("the listing shows %q, which belongs to someone else", hidden) + } + } + // The server's own state is an admin's business, not a guest's. + for _, secret := range []string{"Free disk", "Tokens", "boss"} { + if strings.Contains(page, secret) { + t.Errorf("a plain token's listing exposes %q", secret) + } + } + // An owner column would be a column of one repeated name. + if strings.Contains(page, `data-label="Owner"`) { + t.Error("the listing carries an owner column for a reader who owns everything in it") + } + + // The admin, by contrast, sees all three. + resp = h.get(t, "/files", h.admin) + raw, _ = io.ReadAll(resp.Body) + resp.Body.Close() + for _, want := range []string{"mine.bin", "theirs.bin", "nobodys.bin"} { + if !strings.Contains(string(raw), want) { + t.Errorf("the admin listing is missing %q", want) + } + } +} + +// Anonymous uploads are recorded against nobody, so the page says so rather +// than pretending to be empty or refusing outright. +func TestFilesPageAsksAnonymousVisitorsToLogIn(t *testing.T) { + h := newHarness(t, nil) + h.upload(t, []byte("nobodys"), map[string]string{ + "Content-Disposition": `attachment; filename="nobodys.bin"`}).Body.Close() + + resp := h.get(t, "/files", "") + raw, _ := io.ReadAll(resp.Body) + resp.Body.Close() + page := string(raw) + + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %s, want 200: the page exists, it just needs a token", resp.Status) + } + if !strings.Contains(page, `href="/login?next=files`) { + t.Error("the page does not offer a way to log in and come back") + } + if strings.Contains(page, "nobodys.bin") { + t.Error("an anonymous visitor is shown files they cannot be known to own") + } +} + +// A token holder may delete what they uploaded, and lands back on the listing. +func TestOwnerDeletesFromTheListing(t *testing.T) { + h := newHarness(t, nil) + res := decode[uploadResult](t, h.upload(t, []byte("mine"), map[string]string{ + "Authorization": "Bearer " + h.token, + "Content-Disposition": `attachment; filename="mine.bin"`})) + + client := *h.ts.Client() + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + req, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", strings.NewReader("from=files")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "text/html") + req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.token}) + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("status = %s, want 303", resp.Status) + } + if loc := resp.Header.Get("Location"); loc != "/files" { + t.Errorf("Location = %q, want /files", loc) + } + if _, err := h.store.Get(res.ID, h.now); err == nil { + t.Error("the owner's own file was not deleted") + } +} + +// The cookie is the credential a browser actually uses for this page. +func TestFilesPageAcceptsTheSession(t *testing.T) { + h := newHarness(t, nil) + req, _ := http.NewRequest("GET", h.ts.URL+"/files", nil) req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.admin}) resp, err := h.ts.Client().Do(req) if err != nil { @@ -1232,11 +1334,11 @@ func TestAdminPageAcceptsTheSession(t *testing.T) { } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - t.Fatalf("GET /admin with an admin cookie => %s", resp.Status) + t.Fatalf("GET /files with an admin cookie => %s", resp.Status) } } -func TestAdminPageListsEveryoneAndHidesExpired(t *testing.T) { +func TestAdminSeesEveryFileAndNoExpiredOnes(t *testing.T) { h := newHarness(t, nil) // One anonymous, one owned, one that will have expired by the time the @@ -1252,18 +1354,18 @@ func TestAdminPageListsEveryoneAndHidesExpired(t *testing.T) { "Content-Disposition": `attachment; filename="expired.bin"`}).Body.Close() h.now = clock.Add(2 * time.Hour) - resp := h.get(t, "/admin", h.admin) + resp := h.get(t, "/files", h.admin) defer resp.Body.Close() raw, _ := io.ReadAll(resp.Body) page := string(raw) for _, want := range []string{"anonymous.bin", "owned.bin", "friends-file", "friend"} { if !strings.Contains(page, want) { - t.Errorf("the admin page does not list %q", want) + t.Errorf("the admin listing does not list %q", want) } } if strings.Contains(page, "expired.bin") { - t.Error("the admin page lists an expired file as though it were still stored") + t.Error("the admin listing shows an expired file as though it were still stored") } // Token names and limits are shown; nothing secret is. if !strings.Contains(page, "boss") { @@ -1271,7 +1373,7 @@ func TestAdminPageListsEveryoneAndHidesExpired(t *testing.T) { } for _, secret := range []string{h.admin, h.token} { if strings.Contains(page, secret) { - t.Error("the admin page echoes a token secret") + t.Error("the listing echoes a token secret") } if strings.Contains(page, auth.HashSecret(secret)) { t.Error("the admin page exposes a token hash") @@ -1279,12 +1381,12 @@ func TestAdminPageListsEveryoneAndHidesExpired(t *testing.T) { } } -func TestAdminSortIsRestrictedToKnownColumns(t *testing.T) { +func TestFilesSortIsRestrictedToKnownColumns(t *testing.T) { h := newHarness(t, nil) h.upload(t, []byte("x"), nil).Body.Close() for _, sort := range []string{"size", "created", "expires", "name", "owner", "", "../../etc", "nonsense"} { - resp := h.get(t, "/admin?sort="+url.QueryEscape(sort), h.admin) + resp := h.get(t, "/files?sort="+url.QueryEscape(sort), h.admin) resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("sort=%q => %s", sort, resp.Status) @@ -1293,14 +1395,14 @@ func TestAdminSortIsRestrictedToKnownColumns(t *testing.T) { } // Deleting from the table returns to the table rather than to a dead end. -func TestAdminDeleteReturnsToTheTable(t *testing.T) { +func TestAdminDeleteReturnsToTheListing(t *testing.T) { h := newHarness(t, nil) res := decode[uploadResult](t, h.upload(t, []byte("someone else's"), nil)) client := *h.ts.Client() client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } - form := strings.NewReader("from=admin") + form := strings.NewReader("from=files") req, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", form) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "text/html") @@ -1314,8 +1416,8 @@ func TestAdminDeleteReturnsToTheTable(t *testing.T) { if resp.StatusCode != http.StatusSeeOther { t.Fatalf("status = %s, want 303", resp.Status) } - if loc := resp.Header.Get("Location"); loc != "/admin" { - t.Errorf("Location = %q, want /admin", loc) + if loc := resp.Header.Get("Location"); loc != "/files" { + t.Errorf("Location = %q, want /files", loc) } if _, err := h.store.Get(res.ID, h.now); err == nil { t.Error("the file was not deleted") @@ -1327,7 +1429,7 @@ func TestAdminDeleteStillRequiresAdmin(t *testing.T) { h := newHarness(t, nil) res := decode[uploadResult](t, h.upload(t, []byte("not yours"), nil)) - form := strings.NewReader("from=admin") + form := strings.NewReader("from=files") req, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", form) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") @@ -1342,9 +1444,10 @@ func TestAdminDeleteStillRequiresAdmin(t *testing.T) { } } -// The header link is the only way to discover the page, so it must appear for -// an admin and never for anyone else. -func TestAdminLinkIsShownOnlyToAdmins(t *testing.T) { +// The header link is the only way to discover the page, so it has to appear +// for everyone who has a listing to see - which is anyone logged in, not just +// an admin - and for nobody who does not. +func TestFilesLinkIsShownToEveryoneLoggedIn(t *testing.T) { h := newHarness(t, nil) for _, c := range []struct { who string @@ -1352,7 +1455,7 @@ func TestAdminLinkIsShownOnlyToAdmins(t *testing.T) { want bool }{ {"anonymous", "", false}, - {"a non-admin token", h.token, false}, + {"a plain token", h.token, true}, {"an admin token", h.admin, true}, } { req, _ := http.NewRequest("GET", h.ts.URL+"/", nil) @@ -1365,8 +1468,8 @@ func TestAdminLinkIsShownOnlyToAdmins(t *testing.T) { } raw, _ := io.ReadAll(resp.Body) resp.Body.Close() - if got := strings.Contains(string(raw), `href="/admin"`); got != c.want { - t.Errorf("admin link shown to %s = %v, want %v", c.who, got, c.want) + if got := strings.Contains(string(raw), `href="/files"`); got != c.want { + t.Errorf("files link shown to %s = %v, want %v", c.who, got, c.want) } } } @@ -1677,9 +1780,9 @@ func TestHeaderReflectsTheSession(t *testing.T) { present []string absent []string }{ - {"anonymous", "", []string{`href="/login"`}, []string{`action="/logout"`, `href="/admin"`}}, - {"a plain token", h.token, []string{`action="/logout"`, ">friend<"}, []string{`href="/login"`, `href="/admin"`}}, - {"an admin token", h.admin, []string{`action="/logout"`, `href="/admin"`, ">boss<"}, []string{`href="/login"`}}, + {"anonymous", "", []string{`href="/login"`}, []string{`action="/logout"`, `href="/files"`}}, + {"a plain token", h.token, []string{`action="/logout"`, ">friend<", `href="/files"`}, []string{`href="/login"`}}, + {"an admin token", h.admin, []string{`action="/logout"`, `href="/files"`, ">boss<"}, []string{`href="/login"`}}, } { for _, path := range []string{"/", "/login"} { req, _ := http.NewRequest("GET", h.ts.URL+path, nil) @@ -1983,7 +2086,7 @@ func TestRotationEndsLiveSessions(t *testing.T) { // Sorting has to compare the underlying values, not their rendered form: two // uploads in the same minute render identically but are not equal. -func TestAdminSortOrdersByValue(t *testing.T) { +func TestFilesSortOrdersByValue(t *testing.T) { h := newHarness(t, nil) // Three files, distinct in every sortable dimension. @@ -2009,7 +2112,7 @@ func TestAdminSortOrdersByValue(t *testing.T) { h.now = clock order := func(sortBy string) []string { - resp := h.get(t, "/admin?sort="+sortBy, h.admin) + resp := h.get(t, "/files?sort="+sortBy, h.admin) raw, _ := io.ReadAll(resp.Body) resp.Body.Close() var ids []string @@ -2037,11 +2140,11 @@ func TestAdminSortOrdersByValue(t *testing.T) { // The listing is a table, and a table needs more room than a form. It also has // to stop being a table on a narrow screen rather than grow a scrollbar. -func TestAdminPageIsLaidOutForATable(t *testing.T) { +func TestFilesPageIsLaidOutForATable(t *testing.T) { h := newHarness(t, nil) h.upload(t, []byte("x"), nil).Body.Close() - resp := h.get(t, "/admin", h.admin) + resp := h.get(t, "/files", h.admin) raw, _ := io.ReadAll(resp.Body) resp.Body.Close() page := string(raw) diff --git a/web/templates/admin.html b/web/templates/files.html similarity index 61% rename from web/templates/admin.html rename to web/templates/files.html index 7a5c64c..6bee912 100644 --- a/web/templates/admin.html +++ b/web/templates/files.html @@ -1,24 +1,48 @@ {{define "content"}} +{{if .NeedsToken}}
-

Administration

+

Files

+

+ Uploads are recorded against the token that made them, so this page needs + one to have anything to show. Log in and it lists the files you uploaded, + with what is left of their lifetime. +

+

+ An upload made without a token belongs to nobody and cannot be listed here. + The link and the delete token handed out at the time are the only way back + to it. +

+

Log in

+
+{{else}} + +
+

{{if .Admin}}All files{{else}}Your files{{end}}

Files
{{.Count}}{{if .Anonymous}} ({{.Anonymous}} anonymous){{end}}
-
Stored
{{.Total}}{{if .Quota}} of {{.Quota}} ({{.QuotaPct}}%){{end}}
- {{if .FreeDisk}}
Free disk
{{.FreeDisk}}
{{end}} + {{if .Admin}} +
Stored
{{.Total}}{{if .Quota}} of {{.Quota}} ({{.QuotaPct}}%){{end}}
+ {{if .FreeDisk}}
Free disk
{{.FreeDisk}}
{{end}} + {{else}} +
Uploaded as
{{.User}}
+
Holding
{{.Listed}}
+ {{end}}
-

Files

+

{{if .Admin}}Every upload{{else}}Uploaded with your token{{end}}

{{if not .Objects}} -

Nothing stored right now.

+

+ {{if .Admin}}Nothing stored right now.{{else}}You have not uploaded anything that is still here. Files you upload while logged in show up on this page until they expire.{{end}} +

{{else}} - + {{if .Admin}}{{end}} @@ -32,12 +56,12 @@ {{.ID}}{{if .Vanity}} ★{{end}} - + {{if $.Admin}}{{end}}
File SizeOwnerOwnerUploaded Expires Actions {{.Size}}{{if .Owner}}{{.Owner}}{{else}}anonymous{{end}}{{if .Owner}}{{.Owner}}{{else}}anonymous{{end}}{{.CreatedAgo}} {{.ExpiresIn}}
- +
@@ -49,6 +73,7 @@ {{end}} +{{if .Admin}}

Tokens

@@ -76,3 +101,6 @@ {{end}}

{{end}} + +{{end}} +{{end}} diff --git a/web/templates/layout.html b/web/templates/layout.html index 9e819f5..57d3bac 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -12,7 +12,7 @@ Uncensored Send