From 0dc31fbafee2dde299276c56e4c94f241137b268 Mon Sep 17 00:00:00 2001 From: Sergei Poljanski Date: Tue, 26 May 2026 17:43:48 +0300 Subject: [PATCH] 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. --- lib/admin_auth.rb | 24 +++++++++++++++++++++++ lib/s3.rb | 50 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 lib/admin_auth.rb create mode 100644 lib/s3.rb diff --git a/lib/admin_auth.rb b/lib/admin_auth.rb new file mode 100644 index 0000000..360fd7f --- /dev/null +++ b/lib/admin_auth.rb @@ -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 diff --git a/lib/s3.rb b/lib/s3.rb new file mode 100644 index 0000000..8647c24 --- /dev/null +++ b/lib/s3.rb @@ -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