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

@ -9,6 +9,7 @@ module AdminACL
RULES = [
[%r{\A/admin/(invoices|gel-rate|crypto-rate)(/|\z)}, 'invoices'],
[%r{\A/admin/bingo(/|\z)}, 'bingo'],
[%r{\A/admin/files(/|\z)}, 'files'],
[%r{\A/admin/(users|audit)(/|\z)}, 'users'],
].freeze

93
lib/files_admin.rb Normal file
View file

@ -0,0 +1,93 @@
# /admin/files: publish files to the public bucket and manage them.
# Registered into AsxpioWeb, so AdminAuth, the ACL (area "files") and CSRF
# apply.
module FilesAdmin
module Helpers
def uploads(value)
(value.is_a?(Hash) ? [value] : Array(value)).select { |f| f.is_a?(Hash) && f[:tempfile] }
end
def read_upload(upload)
upload[:tempfile].read(PublicFile.max_bytes + 1) || ''
end
def note_param
note = params[:note].to_s.strip[0, 500]
note.empty? ? nil : note
end
def fmt_bytes(n)
return "#{n} B" if n < 1024
return format('%.1f kB', n / 1024.0) if n < 1024**2
format('%.1f MB', n / 1024.0**2)
end
end
def self.registered(app)
app.helpers Helpers
app.get '/admin/files' do
@page_title = 'Files · admin'
@noindex = true
@files = PublicFile.order(Sequel.desc(:created_at), Sequel.desc(:id)).all
@flash = session.delete(:files_flash)
erb :'admin/files/index'
end
app.post '/admin/files' do
list = uploads(params[:files])
done, failed = [], []
list.each do |up|
file = PublicFile.publish(up[:filename], read_upload(up), by: current_user.username,
note: note_param, strip: params[:keep_metadata] != '1')
audit!('files.upload', file.key, "#{file.size} bytes")
done << file.filename
rescue PublicFile::Error => e
failed << e.message
end
failed << 'no file chosen' if list.empty?
session[:files_flash] = [("Published #{done.join(', ')}." if done.any?), *failed].compact.join(' | ')
redirect '/admin/files'
end
app.get '/admin/files/:id' do
@file = PublicFile[params[:id].to_i] or halt 404
@page_title = "#{@file.filename} · files admin"
@noindex = true
@flash = session.delete(:files_flash)
erb :'admin/files/show'
end
# Edits the note and, when a file is sent, replaces the contents under
# the same URL. The extension has to match: it decides how the object
# is served.
app.post '/admin/files/:id' do
file = PublicFile[params[:id].to_i] or halt 404
begin
if (up = uploads(params[:file]).first)
if PublicFile.ext_of(PublicFile.clean_name(up[:filename])) != PublicFile.ext_of(file.filename)
raise PublicFile::Error, "the replacement must be a .#{PublicFile.ext_of(file.filename)} file; publish other types as a new file"
end
file.store!(read_upload(up), by: current_user.username, strip: params[:keep_metadata] != '1')
audit!('files.replace', file.key, "#{file.size} bytes")
end
if file.note != note_param
file.update(note: note_param, updated_at: Time.now.utc)
audit!('files.edit', file.key, file.note)
end
session[:files_flash] = 'Saved.'
rescue PublicFile::Error => e
session[:files_flash] = "Not saved: #{e.message}"
end
redirect "/admin/files/#{file.id}"
end
app.post '/admin/files/:id/delete' do
file = PublicFile[params[:id].to_i] or halt 404
file.remove!
audit!('files.delete', file.key, "#{file.size} bytes")
session[:files_flash] = "Deleted #{file.filename}; its link no longer works."
redirect '/admin/files'
end
end
end

93
lib/public_file.rb Normal file
View file

@ -0,0 +1,93 @@
require 'digest'
require 'securerandom'
require 'sequel'
require_relative 's3'
require_relative 'bingo'
# A file published from /admin/files: an object in the public bucket, which
# anyone can fetch at #url, plus this row. The app's key can only put and
# delete there, so the row is the only way to find the object again.
class PublicFile < Sequel::Model(:public_files)
class Error < StandardError; end
# Types a browser may render in place. The bucket's host is same-site with
# the admin, so anything that can carry script (HTML, SVG, XML, JS) is
# stored as a generic download instead.
INLINE = {
'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif',
'webp' => 'image/webp', 'avif' => 'image/avif', 'pdf' => 'application/pdf',
'txt' => 'text/plain; charset=utf-8', 'md' => 'text/plain; charset=utf-8',
'log' => 'text/plain; charset=utf-8', 'csv' => 'text/plain; charset=utf-8',
'json' => 'text/plain; charset=utf-8', 'asc' => 'text/plain; charset=utf-8',
'mp3' => 'audio/mpeg', 'ogg' => 'audio/ogg', 'wav' => 'audio/wav',
'mp4' => 'video/mp4', 'webm' => 'video/webm'
}.freeze
DOWNLOAD = 'application/octet-stream'.freeze
CACHE = 'public, max-age=300'.freeze # short: a replaced file keeps its URL
class << self
# Own bucket in prod; unset = the app bucket (dev, tests).
def bucket = ENV['PUBLIC_S3_BUCKET'].to_s.empty? ? S3.bucket : ENV['PUBLIC_S3_BUCKET']
def base_url
(ENV['PUBLIC_BASE_URL'] || "#{ENV['S3_PUBLIC_ENDPOINT']}/#{bucket}").chomp('/')
end
def max_bytes = ENV.fetch('PUBLIC_MAX_MB', '50').to_i * 1024 * 1024
def quota_bytes = ENV.fetch('PUBLIC_QUOTA_MB', '2048').to_i * 1024 * 1024
def used_bytes = sum(:size).to_i
# ASCII only, so the key needs no escaping in the URL.
def clean_name(name)
name = File.basename(name.to_s.dup.force_encoding('UTF-8').scrub('-').tr('\\', '/'))
tidy = ->(part) { part.gsub(/[^A-Za-z0-9._-]+/, '-').gsub(/-{2,}/, '-').gsub(/\A[-.]+|[-.]+\z/, '') }
ext = tidy.(File.extname(name).downcase)
base = tidy.(File.basename(name, '.*'))[0, 80]
"#{base.empty? ? 'file' : base}#{".#{ext}" unless ext.empty?}"
end
def ext_of(name) = File.extname(name).delete_prefix('.').downcase
def publish(name, bytes, by:, note: nil, strip: true)
filename = clean_name(name)
file = new(filename: filename, key: "#{SecureRandom.hex(4)}/#{filename}", note: note,
created_at: Time.now.utc)
file.store!(bytes, by: by, strip: strip)
end
end
def url = "#{self.class.base_url}/#{key}"
# Uploads bytes under this row's key and saves the row. Used for a new
# file and for replacing the contents of an existing one.
def store!(bytes, by:, strip: true)
bytes = bytes.b
raise Error, "#{filename}: empty file" if bytes.empty?
raise Error, "#{filename}: larger than #{self.class.max_bytes / 1024 / 1024} MB" if bytes.bytesize > self.class.max_bytes
bytes = strip_image(bytes) if strip
others = self.class.exclude(id: id).sum(:size).to_i
raise Error, "#{filename}: the public bucket quota is used up" if others + bytes.bytesize > self.class.quota_bytes
type = INLINE[self.class.ext_of(filename)]
S3.put(key, bytes, bucket: self.class.bucket, content_type: type || DOWNLOAD, cache_control: CACHE,
content_disposition: type ? 'inline' : %(attachment; filename="#{filename}"))
set(content_type: type || DOWNLOAD, inline: !type.nil?, size: bytes.bytesize,
sha256: Digest::SHA256.hexdigest(bytes), uploaded_by: by, updated_at: Time.now.utc)
save
end
def remove!
S3.delete(key, bucket: self.class.bucket)
destroy
end
private
# Drops Exif and friends (GPS!) from JPEG, PNG and WebP; other types pass.
def strip_image(bytes)
kind = Bingo::Photo.sniff(bytes) or return bytes
Bingo::Photo.strip_metadata(bytes, kind).b
rescue Bingo::Photo::Error, ArgumentError, RangeError, NoMethodError
raise Error, "#{filename}: could not strip the image metadata"
end
end

View file

@ -30,16 +30,21 @@ module S3
ENV.fetch('S3_BUCKET')
end
def put(key, bytes, content_type: 'application/pdf', bucket: self.bucket)
def put(key, bytes, content_type: 'application/pdf', bucket: self.bucket, **headers)
client.put_object(
bucket: bucket,
key: key,
body: bytes,
content_type: content_type
content_type: content_type,
**headers # content_disposition, cache_control
)
key
end
def delete(key, bucket: self.bucket)
client.delete_object(bucket: bucket, key: key)
end
def get(key, bucket: self.bucket)
client.get_object(bucket: bucket, key: key).body.read.b # body is a UTF-8 StringIO
end

View file

@ -8,7 +8,7 @@ require 'uri'
# Admin accounts. Passwords are scrypt hashes from Ruby's OpenSSL (no gem):
# "scrypt$<logN>$<r>$<p>$<salt>$<hash>", base64 salt/hash.
class User < Sequel::Model(:users)
AREAS = %w[invoices bingo users].freeze
AREAS = %w[invoices bingo files users].freeze
PERMS = %w[read write delete].freeze
NAME_RE = /\A[a-z0-9][a-z0-9_.@-]{1,63}\z/
MIN_PASS = 12