Add files page

This commit is contained in:
2026-09-13 11:28:17 +02:00
parent ab0e1b47d8
commit 30826f3671
7 changed files with 247 additions and 81 deletions
+59 -24
View File
@@ -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
+5 -5
View File
@@ -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{
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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".
+143 -40
View File
@@ -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)
@@ -1,24 +1,48 @@
{{define "content"}}
{{if .NeedsToken}}
<section class="card">
<h1>Administration</h1>
<h1>Files</h1>
<p class="hint">
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.
</p>
<p class="hint">
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.
</p>
<p class="actions"><a class="button" href="{{.Base}}login?next=files">Log in</a></p>
</section>
{{else}}
<section class="card">
<h1>{{if .Admin}}All files{{else}}Your files{{end}}</h1>
<dl class="stats">
<dt>Files</dt><dd>{{.Count}}{{if .Anonymous}} <em>({{.Anonymous}} anonymous)</em>{{end}}</dd>
<dt>Stored</dt><dd>{{.Total}}{{if .Quota}} of {{.Quota}} ({{.QuotaPct}}%){{end}}</dd>
{{if .FreeDisk}}<dt>Free disk</dt><dd>{{.FreeDisk}}</dd>{{end}}
{{if .Admin}}
<dt>Stored</dt><dd>{{.Total}}{{if .Quota}} of {{.Quota}} ({{.QuotaPct}}%){{end}}</dd>
{{if .FreeDisk}}<dt>Free disk</dt><dd>{{.FreeDisk}}</dd>{{end}}
{{else}}
<dt>Uploaded as</dt><dd>{{.User}}</dd>
<dt>Holding</dt><dd>{{.Listed}}</dd>
{{end}}
</dl>
</section>
<section class="card">
<h2>Files</h2>
<h2>{{if .Admin}}Every upload{{else}}Uploaded with your token{{end}}</h2>
{{if not .Objects}}
<p class="hint">Nothing stored right now.</p>
<p class="hint">
{{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}}
</p>
{{else}}
<table class="admin files">
<thead>
<tr>
<th><a href="?sort=name">File</a></th>
<th class="num"><a href="?sort=size">Size</a></th>
<th><a href="?sort=owner">Owner</a></th>
{{if .Admin}}<th><a href="?sort=owner">Owner</a></th>{{end}}
<th><a href="?sort=created">Uploaded</a></th>
<th><a href="?sort=expires">Expires</a></th>
<th><span class="visually-hidden">Actions</span></th>
@@ -32,12 +56,12 @@
<span class="id mono">{{.ID}}{{if .Vanity}} <em title="vanity name">★</em>{{end}}</span>
</td>
<td data-label="Size" class="num nowrap">{{.Size}}</td>
<td data-label="Owner">{{if .Owner}}{{.Owner}}{{else}}<em>anonymous</em>{{end}}</td>
{{if $.Admin}}<td data-label="Owner">{{if .Owner}}{{.Owner}}{{else}}<em>anonymous</em>{{end}}</td>{{end}}
<td data-label="Uploaded" class="nowrap" title="{{.Created}}">{{.CreatedAgo}}</td>
<td data-label="Expires" class="nowrap" title="{{.Expires}}">{{.ExpiresIn}}</td>
<td class="actions-cell">
<form method="post" action="{{$.Base}}d/{{.ID}}/delete">
<input type="hidden" name="from" value="admin">
<input type="hidden" name="from" value="files">
<button type="submit" class="danger small"
data-confirm="Delete {{.Filename}}? This cannot be undone.">Delete</button>
</form>
@@ -49,6 +73,7 @@
{{end}}
</section>
{{if .Admin}}
<section class="card">
<h2>Tokens</h2>
<p class="hint">
@@ -76,3 +101,6 @@
{{end}}
</section>
{{end}}
{{end}}
{{end}}
+1 -1
View File
@@ -12,7 +12,7 @@
<a class="brand" href="{{.Base}}">Uncensored&nbsp;Send</a>
<nav>
{{if .User}}
{{if .Admin}}<a href="{{.Base}}admin">Administration</a>{{end}}
<a href="{{.Base}}files">{{if .Admin}}All files{{else}}Your files{{end}}</a>
<span class="who">{{.User}}</span>
<form method="post" action="{{.Base}}logout"><button type="submit" class="link">Log out</button></form>
{{else}}