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/public_file.rb
Normal file
93
lib/public_file.rb
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue