admin: publish files to the public bucket
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:
parent
f5d0ce9b48
commit
4c10221727
15 changed files with 511 additions and 7 deletions
93
lib/files_admin.rb
Normal file
93
lib/files_admin.rb
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue