admin: publish files to the public bucket
All checks were successful
Build and Deploy to Production / test (push) Successful in 27s
Build and Deploy to Production / build (push) Successful in 1m3s
Build and Deploy to Production / deploy (push) Successful in 40s

There was no way to put a file up for public use short of running an
S3 client with the uploader key. Add a "files" area to the admin:
upload one or more files, copy the link, edit the note, replace the
contents under the same link, delete. It has its own read/write/delete
permission flags like the other areas, and every action is audited.

The objects go to the storage stack's public bucket and are served by
Garage at s3.asxp.io/public/<key>, not by this app. The app's key can
only put and delete there, so a public_files table is the record of
what was published. Keys get a random prefix: links are not guessable
and names never collide.

That host is same-site with the admin, so a file that can carry script
must not render there. Only a fixed list of types (images, PDF, plain
text, audio, video) is served inline; the rest, HTML and SVG included,
is stored as an attachment. Image metadata is stripped by default, as
for the bingo photos, because of GPS tags.

A per-file limit and a total quota keep the small disk on asxpio-ops
from filling up.

Assisted-by: Claude:fable-5-2
This commit is contained in:
Sergei Poljanski 2026-11-12 05:52:10 +04:00
commit 4c10221727
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
15 changed files with 511 additions and 7 deletions

View file

@ -0,0 +1,51 @@
<%== erb :'admin/_nav', layout: false %>
<div class="header">
<h1>Files</h1>
<p class="tagline">published at <span class="mono"><%= PublicFile.base_url %>/…</span> · <%= @files.size %> file(s), <%= fmt_bytes(PublicFile.used_bytes) %> of <%= fmt_bytes(PublicFile.quota_bytes) %></p>
</div>
<% if @flash %><div class="form-error-base"><div><%= @flash %></div></div><% end %>
<% if can?('files', 'write') %>
<section class="section">
<h2>Publish</h2>
<form class="invoice-form" method="post" action="/admin/files" enctype="multipart/form-data">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<div class="field"><label for="files">Files <span class="optional">(≤ <%= fmt_bytes(PublicFile.max_bytes) %> each)</span></label><input type="file" id="files" name="files[]" multiple required /></div>
<div class="field"><label for="note">Note <span class="optional">(only shown here)</span></label><input type="text" id="note" name="note" maxlength="500" /></div>
<p><label><input type="checkbox" name="keep_metadata" value="1" /> keep image metadata <span class="optional">(Exif, GPS; stripped by default)</span></label></p>
<p class="optional">Anyone with the link can fetch the file. Images, PDF, plain text, audio and video open in the browser; everything else is served as a download.</p>
<button type="submit" class="download-button">Publish</button>
</form>
</section>
<% end %>
<section class="section">
<% if @files.empty? %>
<p>Nothing published yet.</p>
<% else %>
<table class="invoice-list">
<thead><tr><th>File</th><th>Note</th><th class="num">Size</th><th>Uploaded</th><th></th></tr></thead>
<tbody>
<% @files.each do |f| %>
<tr>
<td class="mono"><a href="<%= f.url %>" target="_blank" rel="noopener"><%= f.filename %></a></td>
<td><%= f.note %></td>
<td class="num mono"><%= fmt_bytes(f.size) %></td>
<td><span class="mono"><%= f.updated_at.strftime('%Y-%m-%d') %></span> <%= f.uploaded_by %></td>
<td class="actions">
<button type="button" class="link-button" data-copy="<%= f.url %>">copy link</button>
· <a href="/admin/files/<%= f.id %>"><%= can?('files', 'write') ? 'edit' : 'view' %></a>
</td>
</tr>
<% end %>
</tbody>
</table>
<% end %>
</section>
<script>
document.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', async () => {
await navigator.clipboard.writeText(b.dataset.copy);
b.textContent = 'copied';
}));
</script>

View file

@ -0,0 +1,39 @@
<%== erb :'admin/_nav', layout: false %>
<div class="header">
<h1><%= @file.filename %></h1>
<p class="tagline"><a href="/admin/files">← files</a> · <%= @file.inline ? 'opens in the browser' : 'served as a download' %></p>
</div>
<% if @flash %><div class="form-error-base"><div><%= @flash %></div></div><% end %>
<section class="section">
<p class="mono"><a href="<%= @file.url %>" target="_blank" rel="noopener"><%= @file.url %></a></p>
<% if @file.content_type.start_with?('image/') %>
<p><img src="<%= @file.url %>" alt="" style="max-width: 100%; max-height: 280px" /></p>
<% end %>
<p class="optional"><%= fmt_bytes(@file.size) %> · <%= @file.content_type %> · sha256 <span class="mono"><%= @file.sha256 %></span></p>
<p class="optional">Published <%= @file.created_at.strftime('%Y-%m-%d %H:%M') %> UTC, last changed <%= @file.updated_at.strftime('%Y-%m-%d %H:%M') %> UTC by <%= @file.uploaded_by %></p>
</section>
<section class="section">
<h2>Edit</h2>
<form class="invoice-form" method="post" action="/admin/files/<%= @file.id %>" enctype="multipart/form-data">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<fieldset class="plain" <%= 'disabled' unless can?('files', 'write') %>>
<div class="field"><label for="note">Note <span class="optional">(only shown here)</span></label><input type="text" id="note" name="note" value="<%= @file.note %>" maxlength="500" /></div>
<div class="field"><label for="file">Replace the contents <span class="optional">(same link; must be a .<%= PublicFile.ext_of(@file.filename) %> file; caches may show the old one for 5 minutes)</span></label><input type="file" id="file" name="file" /></div>
<p><label><input type="checkbox" name="keep_metadata" value="1" /> keep image metadata</label></p>
<% if can?('files', 'write') %><button type="submit" class="download-button">Save</button><% end %>
</fieldset>
</form>
</section>
<% if can?('files', 'delete') %>
<section class="section">
<h2>Danger zone</h2>
<form method="post" action="/admin/files/<%= @file.id %>/delete" onsubmit="return confirm('Delete this file? Its link stops working.')">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<button type="submit" class="link-button danger">Delete file</button>
</form>
</section>
<% end %>