diff --git a/.env.example b/.env.example index c29282d..046f75b 100644 --- a/.env.example +++ b/.env.example @@ -21,5 +21,11 @@ MAIL_TO=ie@asxp.io # Public bingo hostname (served by the datacenterbingo repo); used here for # IndexNow pings and admin links only. # BINGO_HOST=bingo.localhost +# Bucket and public URL prefix for /admin/files; unset = S3_BUCKET and +# S3_PUBLIC_ENDPOINT/. Limits in MB (defaults 50 and 2048). +# PUBLIC_S3_BUCKET=public +# PUBLIC_BASE_URL=https://s3.asxp.io/public +# PUBLIC_MAX_MB=50 +# PUBLIC_QUOTA_MB=2048 # IndexNow key (8-128 chars, A-Z a-z 0-9 -); unset = no pings, no key file. # INDEXNOW_KEY= diff --git a/.forgejo/workflows/deploy.yaml b/.forgejo/workflows/deploy.yaml index f0cf59b..a0f52c5 100644 --- a/.forgejo/workflows/deploy.yaml +++ b/.forgejo/workflows/deploy.yaml @@ -164,6 +164,8 @@ jobs: CRYPTO_ADDRESSES=${CRYPTO_ADDRESSES} BINGO_HOST=datacenterbingo.xyz BINGO_S3_BUCKET=datacenterbingo-photos + PUBLIC_S3_BUCKET=public + PUBLIC_BASE_URL=https://s3.asxp.io/public INDEXNOW_KEY=${INDEXNOW} EOF chmod 600 /opt/asxpio/.env diff --git a/CLAUDE.md b/CLAUDE.md index e677357..b9571fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ Banking details and registration number are deliberately not on the public page - In-memory `RateLimit` (5 req/hour/IP) — see `lib/rate_limit.rb` - `Rack::Protection::AuthenticityToken` for CSRF (uses session-stored token) - `AdminAuth` (`lib/admin_auth.rb`, a Sinatra extension): session login at `/admin/login` against the `users` table (`lib/user.rb`, scrypt via OpenSSL, no gem). Session holds user id + `session_version` (bump = logged out everywhere). `ADMIN_USER`/`ADMIN_PASSWORD` bootstrap the first superuser on an empty table and otherwise work only while no active superuser exists (recovery; it re-creates that superuser). Login is rate-limited per IP (`LOGIN_RATE_LIMIT`, default 10/15 min). -- Permissions (`lib/admin_acl.rb`): areas `invoices`, `bingo`, `users` × independent flags read/write/delete in `user_permissions` (read + delete without write is valid); superusers bypass. GET → read (…/new, …/duplicate → write), POST …/delete|…/disable → delete, other POST → write. Unmapped /admin paths are superuser-only, and `test/admin_acl_test.rb` fails if a registered admin route isn't mapped — add new admin routes to `AdminACL::RULES`. Views hide forms via `can?(area, perm)`; the server check is the real gate. +- Permissions (`lib/admin_acl.rb`): areas `invoices`, `bingo`, `files`, `users` × independent flags read/write/delete in `user_permissions` (read + delete without write is valid); superusers bypass. GET → read (…/new, …/duplicate → write), POST …/delete|…/disable → delete, other POST → write. Unmapped /admin paths are superuser-only, and `test/admin_acl_test.rb` fails if a registered admin route isn't mapped — add new admin routes to `AdminACL::RULES`. Views hide forms via `can?(area, perm)`; the server check is the real gate. - Users UI (`lib/users_admin.rb`): `/admin/users` create/edit/disable/delete (superusers can also rename; sessions key on id, the audit log keeps old names), permission grid (you can only grant flags you hold; flags you can't grant keep their value; only superusers touch superuser accounts; no self-lockout; last active superuser can't be deleted). New users get a one-time invite link (48 h) by email via `Mailer.account_link`, also shown once to the admin. `/admin/reset` emails a 1 h reset link (same answer for unknown accounts). Tokens are stored as SHA-256 only (`user_tokens`). `/admin/audit` lists `audit_log` (logins, user changes, bingo and invoice actions) — call `audit!(action, target, detail)` in new admin actions. - Timestamps: `Sequel.default_timezone = :utc` (lib/db.rb); always write `Time.now.utc`. - Honeypot field named `website` for spam @@ -142,6 +142,17 @@ The public site (datacenterbingo.xyz) lives in its own repo and container: `git. - Monitor (`lib/bingo_monitor.rb`, `bin/monitor`, migration 017): the `asxpio-monitor` container (same image, compose healthcheck on a heartbeat file) opens one TCP connection per minute to each active `bingo_monitor_targets` row; a handshake or a refusal counts as answered. Results fold into 5-minute `bingo_monitor_samples` per datacenter (upserts) and `bingo_monitor_buckets` (rounds + control hosts, i.e. targets with `datacenter_id` NULL); kept 30 days. Targets are managed at `/admin/bingo/monitor` (bulk lines `slug host[:port] source_url label`, slug `_control` for controls). Only add hosts a public source ties to that one site. The public status page in datacenterbingo reads these tables; migration 017 grants them to the `bingo` role. - No street addresses or coordinates on the page — city level only. +## Public files (`/admin/files`) + +Files published for anyone to fetch: `lib/files_admin.rb`, `lib/public_file.rb`, migration 018, ACL area `files`. + +- Objects go to `PUBLIC_S3_BUCKET` (`public`, a Garage website made by the storage repo; unset = `S3_BUCKET`) and are read at `PUBLIC_BASE_URL/` (`https://s3.asxp.io/public/…`), not through this app. Both are set in `deploy.yaml`. +- The app's key is write-only there (put, delete; no read, no list), so `public_files` is the only record of the objects. Objects uploaded some other way don't show up. +- Key: `<8 hex>/`, fixed for the life of the file. "Replace" re-uploads under the same key and must keep the extension. +- The extension decides how the object is served. `PublicFile::INLINE` types (images, PDF, plain text, audio, video) get their content type and `inline`; everything else, HTML and SVG included, is `application/octet-stream` with `Content-Disposition: attachment`. `s3.asxp.io` is same-site with the admin, so don't add a type that can run script. +- JPEG/PNG/WebP metadata is stripped (`Bingo::Photo.strip_metadata`) unless the uploader ticks "keep". +- Limits: `PUBLIC_MAX_MB` per file (50) and `PUBLIC_QUOTA_MB` in total (2048). Uploads are read into memory. + ## Operational notes - `client_ip` reads `X-Forwarded-For` first (Traefik sets it), falling back to `X-Real-IP` and `REMOTE_ADDR`. @@ -191,7 +202,7 @@ All colors in `public/style.css` are CSS custom properties on `:root` (design to | `ADMIN_USER` | HTTP Basic username for `/admin/*` | Change secret + push. | | `ADMIN_PASSWORD` | HTTP Basic password for `/admin/*` | Generate fresh, update secret + push. **Forgejo doesn't show old values.** Save to password manager. | | `ASXPIO_DB_PASSWORD` | Password for the `asxpio` Postgres role | Update on **both** repos to the same value. Push storage first (re-provisions role), then asxpio. | -| `ASXPIO_S3_ACCESS_KEY` | S3 key id (Garage), read-write on `asxpio-invoices` and the bingo photos | Same dual-repo update. Push storage first. | +| `ASXPIO_S3_ACCESS_KEY` | S3 key id (Garage), read-write on `asxpio-invoices` and the bingo photos, write-only on `public` | Same dual-repo update. Push storage first. | | `ASXPIO_S3_SECRET_KEY` | Matching S3 secret | Same. | | `CRYPTO_ADDRESSES` | **Seed only.** Imported into `payout_addresses` on the first boot with an empty table; afterwards edited in `/admin/invoices/payout-addresses`. | Delete the secret (and its line in deploy.yaml) once the admin page shows the imported addresses. | | `INDEXNOW` | IndexNow key for the bingo (→ `INDEXNOW_KEY`, served at `/.txt`) | New random key (8–128 chars `A-Za-z0-9-`) → update secret → push. The old key file stops being served. | diff --git a/asxpio.rb b/asxpio.rb index 00ea5c1..21a9934 100644 --- a/asxpio.rb +++ b/asxpio.rb @@ -24,6 +24,7 @@ require_relative 'lib/db' require_relative 'lib/admin_auth' require_relative 'lib/s3' require_relative 'lib/bingo_admin' +require_relative 'lib/files_admin' require_relative 'lib/users_admin' # Invoice model needs a live Sequel connection at class-definition time. @@ -39,6 +40,7 @@ if ENV['DATABASE_URL'] require_relative 'lib/invoice_stats' require_relative 'lib/bingo' require_relative 'lib/bingo_monitor' + require_relative 'lib/public_file' Bingo.seed! require_relative 'lib/user' require_relative 'lib/audit' @@ -73,6 +75,7 @@ class AsxpioWeb < Sinatra::Base use Rack::Protection::AuthenticityToken register AdminAuth register BingoAdmin + register FilesAdmin register UsersAdmin Mailer.configure! @@ -185,6 +188,10 @@ class AsxpioWeb < Sinatra::Base no_photo: board.cells.reject(&:photo_key) } end + if can?('files', 'read') + @files = { count: PublicFile.count, bytes: PublicFile.used_bytes, + recent: PublicFile.order(Sequel.desc(:updated_at)).limit(5).all } + end erb :'admin/dashboard' end diff --git a/db/migrations/018_public_files.rb b/db/migrations/018_public_files.rb new file mode 100644 index 0000000..64fbaf2 --- /dev/null +++ b/db/migrations/018_public_files.rb @@ -0,0 +1,20 @@ +Sequel.migration do + change do + # Index of what the admin uploaded to the public bucket (/admin/files). + # The bucket cannot be listed with the app's key, so this is the only + # record of those objects. + create_table(:public_files) do + primary_key :id + String :key, null: false, unique: true # object key: <8 hex>/ + String :filename, null: false # sanitised, as in the key + String :content_type, null: false + TrueClass :inline, null: false, default: false # false = served as a download + Bignum :size, null: false + String :sha256, null: false + String :note, text: true + String :uploaded_by + DateTime :created_at, null: false + DateTime :updated_at, null: false + end + end +end diff --git a/lib/admin_acl.rb b/lib/admin_acl.rb index 4e66420..387fd02 100644 --- a/lib/admin_acl.rb +++ b/lib/admin_acl.rb @@ -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 diff --git a/lib/files_admin.rb b/lib/files_admin.rb new file mode 100644 index 0000000..d028838 --- /dev/null +++ b/lib/files_admin.rb @@ -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 diff --git a/lib/public_file.rb b/lib/public_file.rb new file mode 100644 index 0000000..63192f1 --- /dev/null +++ b/lib/public_file.rb @@ -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 diff --git a/lib/s3.rb b/lib/s3.rb index de5a453..6373df4 100644 --- a/lib/s3.rb +++ b/lib/s3.rb @@ -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 diff --git a/lib/user.rb b/lib/user.rb index 08bf27f..8184498 100644 --- a/lib/user.rb +++ b/lib/user.rb @@ -8,7 +8,7 @@ require 'uri' # Admin accounts. Passwords are scrypt hashes from Ruby's OpenSSL (no gem): # "scrypt$$$

$$", 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 diff --git a/test/files_admin_test.rb b/test/files_admin_test.rb new file mode 100644 index 0000000..a4bb7e2 --- /dev/null +++ b/test/files_admin_test.rb @@ -0,0 +1,152 @@ +require_relative 'test_helper' + +class FilesAdminTest < Minitest::Test + include Rack::Test::Methods + include AdminLogin + + def app + AsxpioWeb + end + + def setup + skip 'TEST_DATABASE_URL not set' unless TestDb.available? + TestDb.clean_users! + DB.connection[:public_files].delete + DB.connection[:audit_log].delete + ENV['PUBLIC_S3_BUCKET'] = 'public' + ENV['PUBLIC_BASE_URL'] = 'https://s3.example/public' + end + + def teardown + %w[PUBLIC_S3_BUCKET PUBLIC_BASE_URL PUBLIC_MAX_MB PUBLIC_QUOTA_MB].each { |k| ENV.delete(k) } + end + + def token + get '/admin/files' + last_response.body[/name="authenticity_token" value="([^"]+)"/, 1] || + (get('/admin'); last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]) + end + + def upload(name, data = 'hello') + Rack::Test::UploadedFile.new(StringIO.new(data), 'application/octet-stream', true, original_filename: name) + end + + # Runs the block with S3 puts and deletes recorded instead of sent. + def recording + puts_to, deleted = [], [] + S3.client.stub(:put_object, ->(**o) { puts_to << o }) do + S3.client.stub(:delete_object, ->(**o) { deleted << o }) { yield puts_to, deleted } + end + end + + def as(flags) + u = User.create(username: 'user1', password: 'user-password-1') + UserPermission.create(user_id: u.id, area: 'files', **flags.to_h { |f| [:"can_#{f}", true] }) + admin_login!(username: 'user1', password: 'user-password-1') + end + + def test_clean_name + assert_equal 'My-report-v2.pdf', PublicFile.clean_name('C:\\tmp\\My report (v2).PDF') + assert_equal 'passwd', PublicFile.clean_name('../../etc/passwd') + assert_equal 'file.png', PublicFile.clean_name('отчёт.png') + assert_equal 'file', PublicFile.clean_name('') + end + + def test_publish_inline_and_download + admin_login! + recording do |puts_to, _| + post '/admin/files', { authenticity_token: token, note: 'for the client', + files: [upload('Spec sheet.pdf', '%PDF-1.4'), upload('page.html', ''), upload('logo.svg', '')] } + assert last_response.redirect? + pdf, html, svg = puts_to + assert_equal %w[public public public], puts_to.map { |o| o[:bucket] } + assert_match %r{\A[0-9a-f]{8}/Spec-sheet\.pdf\z}, pdf[:key] + assert_equal ['application/pdf', 'inline'], pdf.values_at(:content_type, :content_disposition) + [html, svg].each do |o| + assert_equal 'application/octet-stream', o[:content_type] + assert_match(/\Aattachment; filename=/, o[:content_disposition]) + end + end + row = PublicFile.first(filename: 'Spec-sheet.pdf') + assert_equal [8, true, 'for the client', ENV['ADMIN_USER']], [row.size, row.inline, row.note, row.uploaded_by] + assert_equal Digest::SHA256.hexdigest('%PDF-1.4'), row.sha256 + assert_equal "https://s3.example/public/#{row.key}", row.url + get '/admin/files' + assert_includes last_response.body, row.url + assert_equal 3, DB.connection[:audit_log].where(action: 'files.upload').count + get '/admin' + assert_includes last_response.body, '

' + end + + def test_metadata_is_stripped_unless_kept + jpeg = "\xFF\xD8".b + "\xFF\xE1\x00\x08Exif\x00\x00".b + "\xFF\xDB\x00\x04\x01\x02".b + "\xFF\xD9".b + admin_login! + recording do |puts_to, _| + post '/admin/files', { authenticity_token: token, files: [upload('a.jpg', jpeg)] } + post '/admin/files', { authenticity_token: token, files: [upload('b.jpg', jpeg)], keep_metadata: '1' } + refute_includes puts_to[0][:body], 'Exif' + assert_includes puts_to[1][:body], 'Exif' + end + end + + def test_limits + admin_login! + ENV['PUBLIC_MAX_MB'] = '1' + recording do |puts_to, _| + post '/admin/files', { authenticity_token: token, files: [upload('big.bin', 'x' * (1024 * 1024 + 1)), upload('empty.txt', '')] } + assert_empty puts_to + get '/admin/files' + assert_includes last_response.body, 'big.bin: larger than 1 MB' + assert_includes last_response.body, 'empty.txt: empty file' + + ENV['PUBLIC_QUOTA_MB'] = '1' + post '/admin/files', { authenticity_token: token, files: [upload('a.bin', 'x' * 700_000)] } + post '/admin/files', { authenticity_token: token, files: [upload('b.bin', 'x' * 700_000)] } + assert_equal ['a.bin'], PublicFile.select_map(:filename) + get '/admin/files' + assert_includes last_response.body, 'quota is used up' + end + end + + def test_edit_replace_and_delete + admin_login! + recording do |puts_to, deleted| + post '/admin/files', { authenticity_token: token, files: [upload('notes.txt', 'one')] } + file = PublicFile.first + post "/admin/files/#{file.id}", { authenticity_token: token, note: 'v2', file: upload('other-name.txt', 'two!') } + file.refresh + assert_equal ['v2', 4, Digest::SHA256.hexdigest('two!')], [file.note, file.size, file.sha256] + assert_equal [file.key, file.key], puts_to.map { |o| o[:key] } # same link + + post "/admin/files/#{file.id}", { authenticity_token: token, note: 'v2', file: upload('x.pdf', '%PDF') } + assert_equal 4, file.refresh.size # other type refused + get "/admin/files/#{file.id}" + assert_includes last_response.body, 'must be a .txt file' + + post "/admin/files/#{file.id}/delete", { authenticity_token: token } + assert_nil PublicFile[file.id] + assert_equal [{ bucket: 'public', key: file.key }], deleted + end + assert_equal %w[files.delete files.edit files.replace files.upload], + DB.connection[:audit_log].where(Sequel.like(:action, 'files.%')).select_order_map(:action) + end + + def test_permissions + PublicFile.create(key: 'aaaaaaaa/a.txt', filename: 'a.txt', content_type: 'text/plain', size: 1, sha256: 'x', + created_at: Time.now.utc, updated_at: Time.now.utc) + file = PublicFile.first + as(%i[read]) + get '/admin/files' + assert last_response.ok? + refute_includes last_response.body, 'Publish' + get "/admin/files/#{file.id}" + assert last_response.ok? + refute_includes last_response.body, 'Danger zone' + post '/admin/files', { authenticity_token: token, files: [upload('b.txt')] } + assert_equal 403, last_response.status + post "/admin/files/#{file.id}/delete", { authenticity_token: token } + assert_equal 403, last_response.status + get '/admin/invoices' + assert_equal 403, last_response.status + end +end diff --git a/views/admin/_nav.erb b/views/admin/_nav.erb index 4b2e1f9..16fa3b0 100644 --- a/views/admin/_nav.erb +++ b/views/admin/_nav.erb @@ -1,5 +1,5 @@