aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web/templates/dashboard/files.html
diff options
context:
space:
mode:
authorgrm <grm@eyesin.space>2026-09-14 23:51:55 +0300
committergrm <grm@eyesin.space>2026-09-14 23:51:55 +0300
commit778cb72c8a0902bd0b8159ebd3bb7eff93f28c83 (patch)
treede21228c247e735591e88acbbf3bf4c83f2142e0 /internal/web/templates/dashboard/files.html
parent5f9fc2a8667a9438b6336d75026f9a455fc9c4ce (diff)
downloadblogspace-778cb72c8a0902bd0b8159ebd3bb7eff93f28c83.tar.gz
blogspace-778cb72c8a0902bd0b8159ebd3bb7eff93f28c83.tar.bz2
blogspace-778cb72c8a0902bd0b8159ebd3bb7eff93f28c83.zip
Turn the image library into a file library, with a per-blog upload limit
Bloggers want to attach PDFs, archives, audio and other files to posts, not only images. The Images tab becomes Files: any type is accepted, listed by kind with search, paging, rename and multi-file upload, and the editor's paste/drop/"Insert file" takes anything (images are shown, everything else becomes a link). The default limit goes from 5 to 10 MB and the superadmin can override it per blog from /admin/. Files stay in Postgres so one pg_dump is still the whole blog. The bytea column is STORAGE EXTERNAL and /media streams it in substring() slices, so serving never holds a whole file in memory whatever limit a blog gets. Serving any type on the root domain, which carries the session cookie, needs a policy: uploads are typed by sniffing (the extension may only refine a generic sniff to an allowlisted type) and only images, PDF, plain text, audio and video render inline; HTML, SVG, XML, scripts, archives and binaries always go out as application/octet-stream with Content-Disposition: attachment. The body cap moves out of requireAuth into guardPOST, which runs after withBlog has resolved the blog and so knows its limit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
Diffstat (limited to 'internal/web/templates/dashboard/files.html')
-rw-r--r--internal/web/templates/dashboard/files.html95
1 files changed, 95 insertions, 0 deletions
diff --git a/internal/web/templates/dashboard/files.html b/internal/web/templates/dashboard/files.html
new file mode 100644
index 0000000..b42cec2
--- /dev/null
+++ b/internal/web/templates/dashboard/files.html
@@ -0,0 +1,95 @@
+{{define "title"}}{{t "Files"}} · {{.Blog.Title}}{{end}}
+{{define "content"}}
+{{$b := .Blog.Subdomain}}
+<div class="pagehead">
+ <h1>{{t "Files"}}</h1>
+ <p class="lead muted">{{t "Files are stored with your blog. Put one in a post with"}} <em>{{t "Insert file"}}</em> {{t "in the editor, or copy its line from the table."}} {{tf "%d files, %s in use." .Data.count (size .Data.bytes)}}</p>
+</div>
+<div class="card dropzone" id="dropzone">
+ <form method="post" action="/b/{{$b}}/files/upload" enctype="multipart/form-data" id="upload-form">
+ <input type="hidden" name="_csrf" value="{{.CSRF}}">
+ <label>{{t "Upload files"}} <span class="muted">({{tf "any type, up to %s each" .Data.limit}})</span><br><input type="file" name="file" id="upload-input" multiple required></label>
+ <p class="actionrow"><button type="submit">{{t "Upload"}}</button> <span class="muted small" id="upload-hint" hidden>{{t "…or drop files anywhere on this box."}}</span></p>
+ <ul class="progress" id="upload-progress"></ul>
+ </form>
+</div>
+<form method="get" class="filter files-filter">
+ <span class="kinds">
+ <a href="/b/{{$b}}/files{{if .Data.q}}?q={{.Data.q}}{{end}}"{{if not .Data.kind}} class="active"{{end}}>{{t "All"}}</a>
+ {{range .Data.kinds}}<a href="/b/{{$b}}/files?kind={{.}}{{if $.Data.q}}&amp;q={{$.Data.q}}{{end}}"{{if eq . $.Data.kind}} class="active"{{end}}>{{t (index $.Data.kindNames .)}}</a>{{end}}
+ </span>
+ {{if .Data.kind}}<input type="hidden" name="kind" value="{{.Data.kind}}">{{end}}
+ <input type="search" name="q" value="{{.Data.q}}" placeholder="{{t "Search files"}}" size="18"> <button type="submit" class="small">{{t "Search"}}</button>
+</form>
+<div class="card">
+{{if .Data.files}}
+<table class="files">
+ <tr><th></th><th>{{t "Name"}}</th><th>{{t "Kind"}}</th><th>{{t "Size"}}</th><th>{{t "Date"}}</th><th>Markdown</th><th></th></tr>
+ {{range .Data.files}}<tr>
+ <td class="icon">{{if eq .Kind "image"}}<a href="/b/{{$b}}/media/{{.ID}}" target="_blank"><img class="thumb" src="/b/{{$b}}/media/{{.ID}}" alt=""></a>{{else}}<span class="badge">{{.Badge}}</span>{{end}}</td>
+ <td class="name"><a href="/b/{{$b}}/media/{{.ID}}" target="_blank">{{.Filename}}</a>
+ <details class="inline"><summary class="mini">{{t "rename"}}</summary>
+ <form method="post" action="/b/{{$b}}/files/{{.ID}}/rename" class="inline">
+ <input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="back" value="{{$.Data.self}}">
+ <input type="text" name="filename" value="{{.Filename}}" maxlength="120" required size="18">
+ <button class="mini">{{t "Save"}}</button>
+ </form></details></td>
+ <td class="nowrap">{{t (index $.Data.kindNames .Kind)}}</td>
+ <td class="nowrap">{{size .Size}}</td>
+ <td class="nowrap">{{date .CreatedAt}}</td>
+ <td><input class="copy" readonly value="{{filemd .}}" onclick="this.select()"></td>
+ <td class="nowrap"><a href="/b/{{$b}}/media/{{.ID}}?download">{{t "download"}}</a> &middot;
+ <form method="post" action="/b/{{$b}}/files/{{.ID}}/delete" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="back" value="{{$.Data.self}}"><button class="mini danger">{{t "delete"}}</button></form></td>
+ </tr>{{end}}
+</table>
+{{else if or .Data.q .Data.kind}}<p class="muted">{{t "No files match."}}</p>
+{{else}}<p class="muted">{{t "No files yet."}}</p>{{end}}
+</div>
+{{if gt .Data.lastPage 1}}
+<div class="pager">
+ {{if gt .Data.pageNum 1}}<a href="{{.Data.prevURL}}">&larr; {{t "Newer"}}</a>{{end}}
+ <span>{{tf "Page %d of %d" .Data.pageNum .Data.lastPage}}</span>
+ {{if lt .Data.pageNum .Data.lastPage}}<a href="{{.Data.nextURL}}">{{t "Older"}} &rarr;</a>{{end}}
+</div>
+{{end}}
+<script>
+(function () {
+ var zone = document.getElementById("dropzone"), form = document.getElementById("upload-form"), inp = document.getElementById("upload-input"), list = document.getElementById("upload-progress");
+ if (!window.fetch || !window.FormData || !zone || !inp) return; // old browser: the plain form still works
+ document.getElementById("upload-hint").hidden = false;
+ var busy = false;
+ function line(text) { var li = document.createElement("li"); li.textContent = text; list.appendChild(li); return li; }
+ function upload(files) {
+ if (busy || !files.length) return;
+ busy = true;
+ var done = 0, i = 0;
+ function next() {
+ if (i >= files.length) {
+ busy = false;
+ if (done) location.href = {{.Data.self}} + ({{.Data.self}}.indexOf("?") < 0 ? "?ok=" : "&ok=") + encodeURIComponent({{t "Uploaded %d files."}}.replace("%d", done));
+ return;
+ }
+ var f = files[i++], li = line(f.name + " — " + {{t "uploading…"}});
+ var fd = new FormData();
+ fd.append("_csrf", {{.CSRF}});
+ fd.append("file", f);
+ fetch(form.action, { method: "POST", body: fd, credentials: "same-origin", headers: { Accept: "application/json" } })
+ .then(function (r) { return r.json().then(function (j) { if (!r.ok || j.error) throw new Error(j.error || {{t "upload failed"}}); return j; }); })
+ .then(function () { li.textContent = f.name + " — " + {{t "uploaded"}}; done++; })
+ .catch(function (err) { li.textContent = f.name + " — " + err.message; })
+ .then(next);
+ }
+ next();
+ }
+ form.addEventListener("submit", function (ev) { if (inp.files && inp.files.length) { ev.preventDefault(); upload(inp.files); } });
+ inp.addEventListener("change", function () { if (inp.files && inp.files.length) { var fs = Array.prototype.slice.call(inp.files); inp.value = ""; upload(fs); } });
+ zone.addEventListener("dragover", function (ev) { ev.preventDefault(); zone.className = "card dropzone over"; });
+ zone.addEventListener("dragleave", function () { zone.className = "card dropzone"; });
+ zone.addEventListener("drop", function (ev) {
+ ev.preventDefault();
+ zone.className = "card dropzone";
+ if (ev.dataTransfer && ev.dataTransfer.files) upload(ev.dataTransfer.files);
+ });
+})();
+</script>
+{{end}}