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
|
|
@ -21,5 +21,11 @@ MAIL_TO=ie@asxp.io
|
||||||
# Public bingo hostname (served by the datacenterbingo repo); used here for
|
# Public bingo hostname (served by the datacenterbingo repo); used here for
|
||||||
# IndexNow pings and admin links only.
|
# IndexNow pings and admin links only.
|
||||||
# BINGO_HOST=bingo.localhost
|
# BINGO_HOST=bingo.localhost
|
||||||
|
# Bucket and public URL prefix for /admin/files; unset = S3_BUCKET and
|
||||||
|
# S3_PUBLIC_ENDPOINT/<bucket>. 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 (8-128 chars, A-Z a-z 0-9 -); unset = no pings, no key file.
|
||||||
# INDEXNOW_KEY=
|
# INDEXNOW_KEY=
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,8 @@ jobs:
|
||||||
CRYPTO_ADDRESSES=${CRYPTO_ADDRESSES}
|
CRYPTO_ADDRESSES=${CRYPTO_ADDRESSES}
|
||||||
BINGO_HOST=datacenterbingo.xyz
|
BINGO_HOST=datacenterbingo.xyz
|
||||||
BINGO_S3_BUCKET=datacenterbingo-photos
|
BINGO_S3_BUCKET=datacenterbingo-photos
|
||||||
|
PUBLIC_S3_BUCKET=public
|
||||||
|
PUBLIC_BASE_URL=https://s3.asxp.io/public
|
||||||
INDEXNOW_KEY=${INDEXNOW}
|
INDEXNOW_KEY=${INDEXNOW}
|
||||||
EOF
|
EOF
|
||||||
chmod 600 /opt/asxpio/.env
|
chmod 600 /opt/asxpio/.env
|
||||||
|
|
|
||||||
15
CLAUDE.md
15
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`
|
- In-memory `RateLimit` (5 req/hour/IP) — see `lib/rate_limit.rb`
|
||||||
- `Rack::Protection::AuthenticityToken` for CSRF (uses session-stored token)
|
- `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).
|
- `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.
|
- 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`.
|
- Timestamps: `Sequel.default_timezone = :utc` (lib/db.rb); always write `Time.now.utc`.
|
||||||
- Honeypot field named `website` for spam
|
- 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.
|
- 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.
|
- 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/<key>` (`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>/<sanitised ASCII filename>`, 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
|
## Operational notes
|
||||||
|
|
||||||
- `client_ip` reads `X-Forwarded-For` first (Traefik sets it), falling back to `X-Real-IP` and `REMOTE_ADDR`.
|
- `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_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. |
|
| `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_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. |
|
| `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. |
|
| `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 `/<key>.txt`) | New random key (8–128 chars `A-Za-z0-9-`) → update secret → push. The old key file stops being served. |
|
| `INDEXNOW` | IndexNow key for the bingo (→ `INDEXNOW_KEY`, served at `/<key>.txt`) | New random key (8–128 chars `A-Za-z0-9-`) → update secret → push. The old key file stops being served. |
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ require_relative 'lib/db'
|
||||||
require_relative 'lib/admin_auth'
|
require_relative 'lib/admin_auth'
|
||||||
require_relative 'lib/s3'
|
require_relative 'lib/s3'
|
||||||
require_relative 'lib/bingo_admin'
|
require_relative 'lib/bingo_admin'
|
||||||
|
require_relative 'lib/files_admin'
|
||||||
require_relative 'lib/users_admin'
|
require_relative 'lib/users_admin'
|
||||||
|
|
||||||
# Invoice model needs a live Sequel connection at class-definition time.
|
# 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/invoice_stats'
|
||||||
require_relative 'lib/bingo'
|
require_relative 'lib/bingo'
|
||||||
require_relative 'lib/bingo_monitor'
|
require_relative 'lib/bingo_monitor'
|
||||||
|
require_relative 'lib/public_file'
|
||||||
Bingo.seed!
|
Bingo.seed!
|
||||||
require_relative 'lib/user'
|
require_relative 'lib/user'
|
||||||
require_relative 'lib/audit'
|
require_relative 'lib/audit'
|
||||||
|
|
@ -73,6 +75,7 @@ class AsxpioWeb < Sinatra::Base
|
||||||
use Rack::Protection::AuthenticityToken
|
use Rack::Protection::AuthenticityToken
|
||||||
register AdminAuth
|
register AdminAuth
|
||||||
register BingoAdmin
|
register BingoAdmin
|
||||||
|
register FilesAdmin
|
||||||
register UsersAdmin
|
register UsersAdmin
|
||||||
|
|
||||||
Mailer.configure!
|
Mailer.configure!
|
||||||
|
|
@ -185,6 +188,10 @@ class AsxpioWeb < Sinatra::Base
|
||||||
no_photo: board.cells.reject(&:photo_key)
|
no_photo: board.cells.reject(&:photo_key)
|
||||||
}
|
}
|
||||||
end
|
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'
|
erb :'admin/dashboard'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
20
db/migrations/018_public_files.rb
Normal file
20
db/migrations/018_public_files.rb
Normal file
|
|
@ -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>/<filename>
|
||||||
|
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
|
||||||
|
|
@ -9,6 +9,7 @@ module AdminACL
|
||||||
RULES = [
|
RULES = [
|
||||||
[%r{\A/admin/(invoices|gel-rate|crypto-rate)(/|\z)}, 'invoices'],
|
[%r{\A/admin/(invoices|gel-rate|crypto-rate)(/|\z)}, 'invoices'],
|
||||||
[%r{\A/admin/bingo(/|\z)}, 'bingo'],
|
[%r{\A/admin/bingo(/|\z)}, 'bingo'],
|
||||||
|
[%r{\A/admin/files(/|\z)}, 'files'],
|
||||||
[%r{\A/admin/(users|audit)(/|\z)}, 'users'],
|
[%r{\A/admin/(users|audit)(/|\z)}, 'users'],
|
||||||
].freeze
|
].freeze
|
||||||
|
|
||||||
|
|
|
||||||
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
|
||||||
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
|
||||||
|
|
@ -30,16 +30,21 @@ module S3
|
||||||
ENV.fetch('S3_BUCKET')
|
ENV.fetch('S3_BUCKET')
|
||||||
end
|
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(
|
client.put_object(
|
||||||
bucket: bucket,
|
bucket: bucket,
|
||||||
key: key,
|
key: key,
|
||||||
body: bytes,
|
body: bytes,
|
||||||
content_type: content_type
|
content_type: content_type,
|
||||||
|
**headers # content_disposition, cache_control
|
||||||
)
|
)
|
||||||
key
|
key
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def delete(key, bucket: self.bucket)
|
||||||
|
client.delete_object(bucket: bucket, key: key)
|
||||||
|
end
|
||||||
|
|
||||||
def get(key, bucket: self.bucket)
|
def get(key, bucket: self.bucket)
|
||||||
client.get_object(bucket: bucket, key: key).body.read.b # body is a UTF-8 StringIO
|
client.get_object(bucket: bucket, key: key).body.read.b # body is a UTF-8 StringIO
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ require 'uri'
|
||||||
# Admin accounts. Passwords are scrypt hashes from Ruby's OpenSSL (no gem):
|
# Admin accounts. Passwords are scrypt hashes from Ruby's OpenSSL (no gem):
|
||||||
# "scrypt$<logN>$<r>$<p>$<salt>$<hash>", base64 salt/hash.
|
# "scrypt$<logN>$<r>$<p>$<salt>$<hash>", base64 salt/hash.
|
||||||
class User < Sequel::Model(:users)
|
class User < Sequel::Model(:users)
|
||||||
AREAS = %w[invoices bingo users].freeze
|
AREAS = %w[invoices bingo files users].freeze
|
||||||
PERMS = %w[read write delete].freeze
|
PERMS = %w[read write delete].freeze
|
||||||
NAME_RE = /\A[a-z0-9][a-z0-9_.@-]{1,63}\z/
|
NAME_RE = /\A[a-z0-9][a-z0-9_.@-]{1,63}\z/
|
||||||
MIN_PASS = 12
|
MIN_PASS = 12
|
||||||
|
|
|
||||||
152
test/files_admin_test.rb
Normal file
152
test/files_admin_test.rb
Normal file
|
|
@ -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', '<script>1</script>'), upload('logo.svg', '<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, '<h2><a href="/admin/files">'
|
||||||
|
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
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<nav class="admin-nav">
|
<nav class="admin-nav">
|
||||||
<% [['/admin', 'dashboard', nil], ['/admin/invoices', 'invoices', 'invoices'], ['/admin/bingo', 'bingo', 'bingo'], ['/admin/users', 'users', 'users']].each do |href, label, area| %>
|
<% [['/admin', 'dashboard', nil], ['/admin/invoices', 'invoices', 'invoices'], ['/admin/bingo', 'bingo', 'bingo'], ['/admin/files', 'files', 'files'], ['/admin/users', 'users', 'users']].each do |href, label, area| %>
|
||||||
<% next if area && !can?(area, 'read') %>
|
<% next if area && !can?(area, 'read') %>
|
||||||
<a href="<%= href %>" class="<%= 'active' if href == '/admin' ? request.path == '/admin' : request.path.start_with?(href) %>"><%= label %></a>
|
<a href="<%= href %>" class="<%= 'active' if href == '/admin' ? request.path == '/admin' : request.path.start_with?(href) %>"><%= label %></a>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,31 @@
|
||||||
<p><a href="/admin/bingo">manage</a><% if bingo_public_url %> · <a href="<%= bingo_public_url %>" target="_blank" rel="noopener">public card ↗</a><% end %></p>
|
<p><a href="/admin/bingo">manage</a><% if bingo_public_url %> · <a href="<%= bingo_public_url %>" target="_blank" rel="noopener">public card ↗</a><% end %></p>
|
||||||
</section>
|
</section>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% unless @inv || @bingo %>
|
<% if @files %>
|
||||||
|
<section class="section dash-tile">
|
||||||
|
<h2><a href="/admin/files">Files</a></h2>
|
||||||
|
<div class="stats">
|
||||||
|
<div><b><%= @files[:count] %></b><span>published</span></div>
|
||||||
|
<div><b><%= fmt_bytes(@files[:bytes]) %></b><span>used</span></div>
|
||||||
|
<div><b><%= fmt_bytes(PublicFile.quota_bytes) %></b><span>quota</span></div>
|
||||||
|
</div>
|
||||||
|
<% if @files[:recent].any? %>
|
||||||
|
<table class="invoice-list">
|
||||||
|
<tbody>
|
||||||
|
<% @files[:recent].each do |f| %>
|
||||||
|
<tr>
|
||||||
|
<td class="mono"><a href="/admin/files/<%= f.id %>"><%= f.filename %></a></td>
|
||||||
|
<td class="num mono"><%= fmt_bytes(f.size) %></td>
|
||||||
|
<td class="mono"><%= f.updated_at.strftime('%Y-%m-%d') %></td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% end %>
|
||||||
|
<p><a href="/admin/files">manage</a></p>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
||||||
|
<% unless @inv || @bingo || @files %>
|
||||||
<section class="section"><p>No areas are assigned to your account yet. Ask an administrator for access.</p></section>
|
<section class="section"><p>No areas are assigned to your account yet. Ask an administrator for access.</p></section>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
51
views/admin/files/index.erb
Normal file
51
views/admin/files/index.erb
Normal 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>
|
||||||
39
views/admin/files/show.erb
Normal file
39
views/admin/files/show.erb
Normal 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 %>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue