storage: S3 client wrapper and admin Basic-auth middleware

S3 module wraps aws-sdk-s3 against MinIO. Two clients on purpose:

- The bytes-mover client points at S3_ENDPOINT (internal minio:9000 over
  the storage Docker network).
- The presign-only client points at S3_PUBLIC_ENDPOINT (https://s3.asxp.io
  via Traefik) so the URLs it mints resolve from a browser.

force_path_style on both because subdomain-style URLs would require wildcard
DNS under s3.asxp.io.

AdminAuth is a tiny Rack middleware that 401s any request under /admin
unless HTTP Basic credentials match ADMIN_USER / ADMIN_PASSWORD from env.
Fails closed if either env var is missing.
This commit is contained in:
Sergei Poljanski 2026-05-26 17:43:48 +03:00
commit 0dc31fbafe
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
2 changed files with 74 additions and 0 deletions

24
lib/admin_auth.rb Normal file
View file

@ -0,0 +1,24 @@
require 'rack/auth/basic'
# Rack middleware that gates everything under /admin/* behind HTTP Basic.
# Credentials come from ENV at boot; missing vars fail closed (401 always).
class AdminAuth
def initialize(app)
@app = app
@user = ENV['ADMIN_USER']
@pass = ENV['ADMIN_PASSWORD']
end
def call(env)
return @app.call(env) unless env['PATH_INFO'].to_s.start_with?('/admin')
auth = Rack::Auth::Basic::Request.new(env)
if @user && @pass && auth.provided? && auth.basic? && auth.credentials == [@user, @pass]
@app.call(env)
else
[401,
{ 'content-type' => 'text/plain', 'www-authenticate' => 'Basic realm="asxp.io admin"' },
["Unauthorized\n"]]
end
end
end

50
lib/s3.rb Normal file
View file

@ -0,0 +1,50 @@
require 'aws-sdk-s3'
module S3
module_function
def client
@client ||= Aws::S3::Client.new(
region: ENV.fetch('S3_REGION', 'us-east-1'),
endpoint: ENV.fetch('S3_ENDPOINT'),
access_key_id: ENV.fetch('S3_ACCESS_KEY'),
secret_access_key: ENV.fetch('S3_SECRET_KEY'),
force_path_style: true
)
end
# Separate client whose endpoint is the public hostname; used only to mint
# presigned URLs that a browser can dereference. Bytes never travel through
# this client.
def presign_client
@presign_client ||= Aws::S3::Client.new(
region: ENV.fetch('S3_REGION', 'us-east-1'),
endpoint: ENV.fetch('S3_PUBLIC_ENDPOINT'),
access_key_id: ENV.fetch('S3_ACCESS_KEY'),
secret_access_key: ENV.fetch('S3_SECRET_KEY'),
force_path_style: true
)
end
def bucket
ENV.fetch('S3_BUCKET')
end
def put(key, bytes, content_type: 'application/pdf')
client.put_object(
bucket: bucket,
key: key,
body: bytes,
content_type: content_type
)
key
end
def presigned_url(key, expires_in: 300, filename: nil)
params = { bucket: bucket, key: key }
if filename
params[:response_content_disposition] = %(attachment; filename="#{filename}")
end
Aws::S3::Presigner.new(client: presign_client).presigned_url(:get_object, **params, expires_in: expires_in)
end
end