From c1dfad33302a0b8c3d1140f1c058bac1a07f09eb Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 1 Sep 2026 14:13:36 -0400 Subject: [PATCH 1/3] feat: openapi spec + structured json errors + jsonld --- README.md | 5 + .../api/v4/application_controller.rb | 35 +- app/controllers/api/v4/uploads_controller.rb | 101 +++- .../concerns/json_error_responses.rb | 46 ++ app/controllers/errors_controller.rb | 17 + .../external_uploads_controller.rb | 35 +- app/controllers/open_api_controller.rb | 21 + app/helpers/metadata_helper.rb | 13 + app/helpers/structured_data_helper.rb | 103 ++++ app/models/open_api_spec.rb | 528 ++++++++++++++++++ app/views/docs/pages/api.md | 145 ++++- app/views/layouts/application.html.erb | 26 +- app/views/static_pages/home.html.erb | 4 + config/initializers/inflections.rb | 1 + config/routes.rb | 7 + .../api/v4/error_responses_test.rb | 144 +++++ test/controllers/errors_controller_test.rb | 54 ++ .../external_uploads_controller_test.rb | 53 ++ test/controllers/open_api_controller_test.rb | 124 ++++ .../static_pages_controller_test.rb | 59 +- 20 files changed, 1478 insertions(+), 43 deletions(-) create mode 100644 app/controllers/concerns/json_error_responses.rb create mode 100644 app/controllers/errors_controller.rb create mode 100644 app/controllers/open_api_controller.rb create mode 100644 app/helpers/metadata_helper.rb create mode 100644 app/helpers/structured_data_helper.rb create mode 100644 app/models/open_api_spec.rb create mode 100644 test/controllers/api/v4/error_responses_test.rb create mode 100644 test/controllers/errors_controller_test.rb create mode 100644 test/controllers/open_api_controller_test.rb diff --git a/README.md b/README.md index be6e60a..179fd42 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,11 @@ See `.env.example` for the full list. Key variables: The API uses bearer token authentication. Create an API key from the web dashboard after logging in. +The API surface is published as an OpenAPI 3.2 document at `/openapi.json` and +`/openapi.yaml` (also `/api/openapi.json` / `/api/openapi.yaml`), generated from +`app/models/open_api_spec.rb`. Update that file alongside any change to the +`/api/v4` controllers or `app/views/docs/pages/api.md`. + **Upload a file:** ```bash curl -X POST https://cdn.hackclub.com/api/v4/upload \ diff --git a/app/controllers/api/v4/application_controller.rb b/app/controllers/api/v4/application_controller.rb index ce5efcf..8d8c5ba 100644 --- a/app/controllers/api/v4/application_controller.rb +++ b/app/controllers/api/v4/application_controller.rb @@ -2,15 +2,16 @@ module API module V4 class ApplicationController < ActionController::API include ActionController::HttpAuthentication::Token::ControllerMethods + include JSONErrorResponses attr_reader :current_user, :current_token before_action :authenticate! before_action :set_sentry_context + rescue_from StandardError, with: :handle_error rescue_from ActiveRecord::RecordNotFound, with: :not_found rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity - rescue_from StandardError, with: :handle_error private @@ -20,7 +21,12 @@ def authenticate! end unless @current_token&.active? - return render json: { error: "invalid_auth" }, status: :unauthorized + return render_json_error( + code: :invalid_auth, + status: :unauthorized, + message: "The API key is missing, malformed, invalid, or revoked.", + hint: "Send the key as an `Authorization: Bearer sk_cdn_...` header. Create or rotate a key at https://#{JSONErrorResponses.canonical_host}/api_keys." + ) end @current_user = @current_token.user @@ -32,21 +38,38 @@ def set_sentry_context end def not_found - render json: { error: "Not found" }, status: :not_found + render_json_error( + error: "Not found", + code: :not_found, + status: :not_found, + message: "No resource matched the requested path or identifier.", + hint: "Check the ID. Uploads are scoped to the API key's owner, so another user's upload reads as missing." + ) end def unprocessable_entity(exception) - render json: { + render_json_error( error: "Validation failed", + code: :validation_failed, + status: :unprocessable_entity, + message: exception.record.errors.full_messages.to_sentence.presence || "The request was well-formed but the record could not be saved.", + hint: "Fix the fields listed in `details` and retry.", details: exception.record.errors.full_messages - }, status: :unprocessable_entity + ) end def handle_error(exception) raise exception if Rails.env.local? event = Sentry.capture_exception(exception) - render json: { error: exception.message, error_id: event&.event_id }, status: :internal_server_error + render_json_error( + error: exception.message, + code: :internal_error, + status: :internal_server_error, + message: exception.message, + hint: "This is a bug on our end. Retry later. If it persists, report `error_id` in #cdn-dev on Slack or at https://github.com/hackclub/cdn/issues.", + error_id: event&.event_id + ) end end end diff --git a/app/controllers/api/v4/uploads_controller.rb b/app/controllers/api/v4/uploads_controller.rb index 3315266..d0c8cff 100644 --- a/app/controllers/api/v4/uploads_controller.rb +++ b/app/controllers/api/v4/uploads_controller.rb @@ -10,7 +10,11 @@ def create file = params[:file] unless file.present? - render json: { error: "Missing file parameter" }, status: :bad_request + render_missing_parameter( + "file", + error: "Missing file parameter", + hint: "Send the file as multipart/form-data under the `file` field, e.g. `curl -F \"file=@photo.jpg\"`." + ) return end @@ -33,7 +37,13 @@ def create render json: upload_json(upload), status: :created rescue => e - render json: { error: "Upload failed: #{e.message}" }, status: :unprocessable_entity + render_json_error( + error: "Upload failed: #{e.message}", + code: :upload_failed, + status: :unprocessable_entity, + message: "The file could not be stored: #{e.message}", + hint: "Check the file is readable and under your per-file size limit, then retry. See https://#{JSONErrorResponses.canonical_host}/docs/quotas." + ) end # POST /api/v4/uploads (batch) @@ -41,15 +51,23 @@ def create_batch files = params[:files] unless files.present? && files.is_a?(Array) - render json: { error: "Missing files[] parameter" }, status: :bad_request + render_missing_parameter( + "files[]", + error: "Missing files[] parameter", + hint: "Send each file as multipart/form-data under the repeated `files[]` field, e.g. `curl -F \"files[]=@a.png\" -F \"files[]=@b.png\"`." + ) return end if files.size > BatchUploadService::MAX_FILES_PER_BATCH - render json: { + render_json_error( error: "Too many files", + code: :too_many_files, + status: :bad_request, + message: "Maximum #{BatchUploadService::MAX_FILES_PER_BATCH} files per batch, got #{files.size}.", + hint: "Split the request into batches of #{BatchUploadService::MAX_FILES_PER_BATCH} files or fewer.", detail: "Maximum #{BatchUploadService::MAX_FILES_PER_BATCH} files per batch, got #{files.size}" - }, status: :bad_request + ) return end @@ -70,7 +88,11 @@ def create_from_url url = params[:url] unless url.present? - render json: { error: "Missing url parameter" }, status: :bad_request + render_missing_parameter( + "url", + error: "Missing url parameter", + hint: "POST JSON like {\"url\":\"https://example.com/image.jpg\"} with `Content-Type: application/json`." + ) return end @@ -83,14 +105,20 @@ def create_from_url if current_user.total_storage_bytes > quota_service.current_policy.max_total_storage upload.destroy! usage = quota_service.current_usage - render json: quota_error_json(usage), status: :payment_required + render_quota_error(usage) return end end render json: upload_json(upload), status: :created rescue => e - render json: { error: "Upload failed: #{e.message}" }, status: :unprocessable_entity + render_json_error( + error: "Upload failed: #{e.message}", + code: :upload_failed, + status: :unprocessable_entity, + message: "The source URL could not be fetched or stored: #{e.message}", + hint: "Confirm the URL is publicly reachable (or pass `X-Download-Authorization`), returns a file, and is under your per-file size limit." + ) end # DELETE /api/v4/upload/:id @@ -107,16 +135,32 @@ def rename new_filename = params[:filename].to_s.strip if new_filename.blank? - render json: { error: "Missing filename parameter" }, status: :bad_request + render_missing_parameter( + "filename", + error: "Missing filename parameter", + hint: "Send JSON like {\"filename\":\"new-name.png\"}. Keep the extension so the CDN serves the right content type." + ) return end upload.rename!(new_filename) render json: upload_json(upload) rescue ActiveRecord::RecordNotFound - render json: { error: "Upload not found" }, status: :not_found + render_json_error( + error: "Upload not found", + code: :upload_not_found, + status: :not_found, + message: "No upload with that ID belongs to this API key's owner.", + hint: "List your uploads in the dashboard, or check the ID from the original upload response." + ) rescue => e - render json: { error: "Rename failed: #{e.message}" }, status: :unprocessable_entity + render_json_error( + error: "Rename failed: #{e.message}", + code: :rename_failed, + status: :unprocessable_entity, + message: "The upload could not be renamed: #{e.message}", + hint: "Filenames must be non-blank and are sanitized before storage. Renaming changes the CDN URL." + ) end # DELETE /api/v4/uploads/batch @@ -124,7 +168,11 @@ def destroy_batch ids = Array(params[:ids]).reject(&:blank?) if ids.empty? - render json: { error: "Missing ids[] parameter" }, status: :bad_request + render_missing_parameter( + "ids[]", + error: "Missing ids[] parameter", + hint: "Send JSON like {\"ids\":[\"\",\"\"]}." + ) return end @@ -143,6 +191,17 @@ def destroy_batch private + def render_missing_parameter(name, error:, hint:) + render_json_error( + error: error, + code: :missing_parameter, + status: :bad_request, + message: "Required parameter `#{name}` is missing or blank.", + hint: hint, + parameter: name + ) + end + def check_quota # For direct uploads, check file size before processing if params[:file].present? @@ -153,14 +212,18 @@ def check_quota # Check per-file size limit if file_size > policy.max_file_size usage = quota_service.current_usage - render json: quota_error_json(usage, "File size exceeds your limit of #{ActiveSupport::NumberHelper.number_to_human_size(policy.max_file_size)} per file"), status: :payment_required + render_quota_error( + usage, + "File size exceeds your limit of #{ActiveSupport::NumberHelper.number_to_human_size(policy.max_file_size)} per file", + code: :file_too_large + ) return end # Check if upload would exceed total storage quota unless quota_service.can_upload?(file_size) usage = quota_service.current_usage - render json: quota_error_json(usage), status: :payment_required + render_quota_error(usage) nil end end @@ -168,16 +231,20 @@ def check_quota # For batch uploads, quota is handled by BatchUploadService end - def quota_error_json(usage, custom_message = nil) - { + def render_quota_error(usage, custom_message = nil, code: :quota_exceeded) + render_json_error( error: custom_message || "Storage quota exceeded", + code: code, + status: :payment_required, + message: custom_message || "This upload would exceed the storage quota for your account (#{usage[:policy]} tier).", + hint: "Delete files you no longer need, or ask for a higher tier. See https://#{JSONErrorResponses.canonical_host}/docs/quotas.", quota: { storage_used: usage[:storage_used], storage_limit: usage[:storage_limit], quota_tier: usage[:policy], percentage_used: usage[:percentage_used] } - } + ) end def upload_json(upload) diff --git a/app/controllers/concerns/json_error_responses.rb b/app/controllers/concerns/json_error_responses.rb new file mode 100644 index 0000000..3414dec --- /dev/null +++ b/app/controllers/concerns/json_error_responses.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +# Every error has the same shape: +# +# { +# "error": "invalid_auth", # legacy human/short string, kept stable +# "code": "invalid_auth", # stable machine-readable code +# "message": "...", # what went wrong +# "hint": "...", # how to fix it +# "status": 401, +# "documentation_url": "https://cdn.hackclub.com/docs/api" +# } +module JSONErrorResponses + extend ActiveSupport::Concern + + DOCUMENTATION_PATH = "/docs/api" + + STATUS_ALIASES = { unprocessable_entity: :unprocessable_content }.freeze + + def self.canonical_host = ENV["CDN_HOST"].presence || "cdn.hackclub.com" + + def self.documentation_url = "https://#{canonical_host}#{DOCUMENTATION_PATH}" + + def self.payload(code:, status:, message:, hint: nil, error: nil, **extra) + body = { + error: error || code.to_s, + code: code.to_s, + message: message + } + body[:hint] = hint if hint.present? + body.merge!(extra) + body[:status] = Rack::Utils.status_code(STATUS_ALIASES.fetch(status, status)) + body[:documentation_url] = documentation_url + body + end + + private + + def render_json_error(code:, status:, message:, hint: nil, error: nil, **extra) + render json: JSONErrorResponses.payload( + code: code, status: status, message: message, hint: hint, error: error, **extra + ), status: status + end + + def documentation_url = JSONErrorResponses.documentation_url +end diff --git a/app/controllers/errors_controller.rb b/app/controllers/errors_controller.rb new file mode 100644 index 0000000..6c466b7 --- /dev/null +++ b/app/controllers/errors_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class ErrorsController < ActionController::API + include JSONErrorResponses + + def not_found + render_json_error( + code: :route_not_found, + status: :not_found, + message: "No route matches #{request.request_method} #{request.path}.", + hint: "Read the machine-readable API surface at https://#{JSONErrorResponses.canonical_host}/openapi.json, or the docs at #{JSONErrorResponses.documentation_url}.", + openapi_url: "https://#{JSONErrorResponses.canonical_host}/openapi.json", + path: request.path, + method: request.request_method + ) + end +end diff --git a/app/controllers/external_uploads_controller.rb b/app/controllers/external_uploads_controller.rb index 7c340f9..3b29842 100644 --- a/app/controllers/external_uploads_controller.rb +++ b/app/controllers/external_uploads_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ExternalUploadsController < ApplicationController + include JSONErrorResponses + skip_before_action :require_authentication! skip_forgery_protection before_action :set_cors_headers @@ -17,14 +19,25 @@ def show expires_in 1.year, public: true redirect_to upload.assets_url, allow_other_host: true rescue ActiveRecord::RecordNotFound - head :not_found + respond_with_error( + code: :not_found, + status: :not_found, + message: "No upload exists with ID #{params[:id]}.", + hint: "The file may have been deleted. Files are addressed as //; the ID comes from the upload response." + ) end def rescue url = params[:url] if url.blank? - head :bad_request + respond_with_error( + code: :missing_parameter, + status: :bad_request, + message: "Required parameter `url` is missing.", + hint: "Call /rescue?url=.", + parameter: "url" + ) return end @@ -43,13 +56,29 @@ def rescue def set_cors_headers = response.set_header("Access-Control-Allow-Origin", "*") def render_not_found_response(url) - if url.match?(/\.(png|jpe?g)$/i) + if request.format == :json + render_json_error( + code: :original_url_not_found, + status: :not_found, + message: "No file on the CDN was imported from #{url}.", + hint: "Upload it at https://#{JSONErrorResponses.canonical_host} to give it a CDN URL.", + original_url: url + ) + elsif url.match?(/\.(png|jpe?g)$/i) render_error_image else head :not_found end end + def respond_with_error(code:, status:, message:, hint:, **extra) + if request.format == :json + render_json_error(code: code, status: status, message: message, hint: hint, **extra) + else + head status + end + end + def render_error_image svg = <<~SVG diff --git a/app/controllers/open_api_controller.rb b/app/controllers/open_api_controller.rb new file mode 100644 index 0000000..0e2f98e --- /dev/null +++ b/app/controllers/open_api_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class OpenAPIController < ApplicationController + skip_before_action :require_authentication! + + def show + expires_in 1.hour, public: true + set_discovery_headers + + respond_to do |format| + format.json { render json: OpenAPISpec.to_json } + format.yaml { render plain: OpenAPISpec.to_yaml, content_type: "application/yaml" } + end + end + + private + + def set_discovery_headers + response.set_header("Access-Control-Allow-Origin", "*") + end +end diff --git a/app/helpers/metadata_helper.rb b/app/helpers/metadata_helper.rb new file mode 100644 index 0000000..d230b93 --- /dev/null +++ b/app/helpers/metadata_helper.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module MetadataHelper + SITE_NAME = "Hack Club CDN" + SITE_DESCRIPTION = "File hosting for Hack Clubbers. Upload files through the dashboard or the HTTP API and get permanent CDN URLs." + + def canonical_host = ENV["CDN_HOST"].presence || "cdn.hackclub.com" + def canonical_root = "https://#{canonical_host}" + def canonical_url = content_for(:canonical_url).presence || "#{canonical_root}#{request.path}" + def page_title = content_for(:title).presence || SITE_NAME + def page_description = content_for(:description).presence || SITE_DESCRIPTION + def og_image_url = "#{canonical_root}/icon.png" +end diff --git a/app/helpers/structured_data_helper.rb b/app/helpers/structured_data_helper.rb new file mode 100644 index 0000000..9a4664e --- /dev/null +++ b/app/helpers/structured_data_helper.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +# JSON-LD structured data, so agents can read the site's identity without +# scraping the page. +module StructuredDataHelper + GITHUB_URL = "https://github.com/hackclub/cdn" + HACK_CLUB_URL = "https://hackclub.com" + + def json_ld_tag(data) + tag.script(ERB::Util.json_escape(data.to_json).html_safe, type: "application/ld+json") + end + + def home_structured_data + { + "@context" => "https://schema.org", + "@graph" => [ software_application_ld, organization_ld, website_ld ] + } + end + + private + + def software_application_ld + { + "@type" => "SoftwareApplication", + "@id" => "#{canonical_root}/#software-application", + "name" => MetadataHelper::SITE_NAME, + "alternateName" => canonical_host, + "url" => canonical_root, + "description" => MetadataHelper::SITE_DESCRIPTION, + "applicationCategory" => "DeveloperApplication", + "applicationSubCategory" => "File hosting and content delivery", + "operatingSystem" => "Any (web-based)", + "softwareVersion" => OpenAPISpec::API_VERSION, + "image" => og_image_url, + "sameAs" => [ GITHUB_URL ], + "isAccessibleForFree" => true, + "offers" => { + "@type" => "Offer", + "price" => "0", + "priceCurrency" => "USD", + "availability" => "https://schema.org/InStock" + }, + "featureList" => [ + "Upload files from the browser or the HTTP API", + "Permanent CDN URLs for every upload", + "Import files from an existing URL", + "Batch upload and batch delete", + "Per-account storage quotas" + ], + "audience" => { + "@type" => "Audience", + "audienceType" => "Hack Club members and their projects" + }, + "provider" => { "@id" => "#{HACK_CLUB_URL}/#organization" }, + "publisher" => { "@id" => "#{HACK_CLUB_URL}/#organization" }, + "softwareHelp" => { + "@type" => "WebPage", + "name" => "Hack Club CDN documentation", + "url" => "#{canonical_root}/docs" + }, + "potentialAction" => { + "@type" => "CreateAction", + "name" => "Upload a file", + "target" => { + "@type" => "EntryPoint", + "urlTemplate" => "#{canonical_root}/api/v4/upload", + "httpMethod" => "POST", + "contentType" => "multipart/form-data", + "actionApplication" => { "@id" => "#{canonical_root}/#software-application" } + } + } + } + end + + def organization_ld + { + "@type" => "Organization", + "@id" => "#{HACK_CLUB_URL}/#organization", + "name" => "Hack Club", + "url" => HACK_CLUB_URL, + "logo" => "https://assets.hackclub.com/flag-standalone.svg", + "description" => "A nonprofit network of high school makers and coding clubs.", + "sameAs" => [ + "https://github.com/hackclub", + "https://hackclub.com/", + "https://en.wikipedia.org/wiki/Hack_Club" + ] + } + end + + def website_ld + { + "@type" => "WebSite", + "@id" => "#{canonical_root}/#website", + "name" => MetadataHelper::SITE_NAME, + "url" => canonical_root, + "description" => MetadataHelper::SITE_DESCRIPTION, + "inLanguage" => "en", + "publisher" => { "@id" => "#{HACK_CLUB_URL}/#organization" }, + "about" => { "@id" => "#{canonical_root}/#software-application" } + } + end +end diff --git a/app/models/open_api_spec.rb b/app/models/open_api_spec.rb new file mode 100644 index 0000000..cb54f21 --- /dev/null +++ b/app/models/open_api_spec.rb @@ -0,0 +1,528 @@ +# frozen_string_literal: true + +class OpenAPISpec + SPEC_VERSION = "3.2.0" + API_VERSION = "4.0.0" + + class << self + def host = ENV["CDN_HOST"].presence || "cdn.hackclub.com" + + def base_url = "https://#{host}" + + def as_json(*) = document + + def to_json(*) = JSON.pretty_generate(document) + + def to_yaml = document.deep_stringify_keys.to_yaml + + def document + { + openapi: SPEC_VERSION, + info: info, + externalDocs: { + description: "Hack Club CDN documentation", + url: "#{base_url}/docs" + }, + servers: [ { url: base_url, description: "Production" } ], + security: [ { bearerAuth: [] } ], + tags: tags, + paths: paths, + components: components + } + end + + private + + def info + { + title: "Hack Club CDN API", + summary: "Upload, rename and delete files on the Hack Club CDN.", + description: <<~MARKDOWN, + File hosting for Hack Clubbers. Upload a file and get back a permanent + `https://#{host}//` URL. + + Authenticate every `/api/v4` request with an API key created at + #{base_url}/api_keys, sent as `Authorization: Bearer sk_cdn_...`. + + Errors are always JSON with a stable machine-readable `code`, a + human-readable `message` and a `hint` describing how to fix it. + Storage is quota-limited per user; exceeding a quota returns + `402 Payment Required` with a `quota` object. + MARKDOWN + version: API_VERSION, + termsOfService: "#{base_url}/docs/terms", + contact: { + name: "Hack Club CDN maintainers", + url: "https://github.com/hackclub/cdn/issues" + } + } + end + + def tags + [ + { name: "Uploads", description: "Create, rename and delete files." }, + { name: "Account", description: "Authenticated user and quota information." }, + { name: "API keys", description: "Manage the key making the request." }, + { name: "Files", description: "Public, unauthenticated file delivery." } + ] + end + + def paths + { + "/api/v4/me": { + get: { + operationId: "getCurrentUser", + tags: [ "Account" ], + summary: "Get the authenticated user and quota usage", + description: "Returns the owner of the API key making the request, plus current storage usage and limits in bytes.", + responses: { + "200": json_response("The authenticated user.", "#/components/schemas/User"), + "401": error_response(:unauthorized) + } + } + }, + "/api/v4/upload": { + post: { + operationId: "createUpload", + tags: [ "Uploads" ], + summary: "Upload a single file", + description: "Uploads one file as multipart/form-data and returns its permanent CDN URL.", + requestBody: { + required: true, + content: { + "multipart/form-data": { + schema: { + type: "object", + required: [ "file" ], + properties: { + file: { type: "string", format: "binary", description: "The file to upload." } + } + } + } + } + }, + responses: { + "201": json_response("The stored upload.", "#/components/schemas/Upload"), + "400": error_response(:bad_request), + "401": error_response(:unauthorized), + "402": error_response(:payment_required), + "422": error_response(:unprocessable_entity) + } + } + }, + "/api/v4/uploads": { + post: { + operationId: "createUploadsBatch", + tags: [ "Uploads" ], + summary: "Upload up to 40 files in one request", + description: "Uploads several files as multipart/form-data. Partial success is normal: successful files are listed in `uploads`, rejected ones in `failed`. Returns 201 when at least one file succeeded, otherwise 422.", + requestBody: { + required: true, + content: { + "multipart/form-data": { + schema: { + type: "object", + required: [ "files" ], + properties: { + files: { + type: "array", + maxItems: BatchUploadService::MAX_FILES_PER_BATCH, + description: "Repeated `files[]` fields, at most #{BatchUploadService::MAX_FILES_PER_BATCH} per request.", + items: { type: "string", format: "binary" } + } + } + } + } + } + }, + responses: { + "201": json_response("At least one file was stored.", "#/components/schemas/BatchUploadResult"), + "400": error_response(:bad_request), + "401": error_response(:unauthorized), + "422": json_response("No file could be stored.", "#/components/schemas/BatchUploadResult") + } + } + }, + "/api/v4/upload_from_url": { + post: { + operationId: "createUploadFromUrl", + tags: [ "Uploads" ], + summary: "Upload a file by fetching a URL", + description: "Downloads the given URL server-side and stores it. The source URL must be publicly reachable, or supply credentials with the `X-Download-Authorization` header.", + parameters: [ + { + name: "X-Download-Authorization", + in: "header", + required: false, + description: "Sent as the `Authorization` header when fetching the source URL.", + schema: { type: "string" } + } + ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: [ "url" ], + properties: { + url: { type: "string", format: "uri", description: "Publicly reachable URL of the file to store." } + } + } + } + } + }, + responses: { + "201": json_response("The stored upload.", "#/components/schemas/Upload"), + "400": error_response(:bad_request), + "401": error_response(:unauthorized), + "402": error_response(:payment_required), + "422": error_response(:unprocessable_entity) + } + } + }, + "/api/v4/uploads/{id}/rename": { + patch: { + operationId: "renameUpload", + tags: [ "Uploads" ], + summary: "Rename an upload", + description: "Changes the filename segment of the CDN URL. The old URL stops resolving, so update any references.", + parameters: [ upload_id_parameter ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: [ "filename" ], + properties: { + filename: { type: "string", description: "New filename, including extension." } + } + } + } + } + }, + responses: { + "200": json_response("The renamed upload.", "#/components/schemas/Upload"), + "400": error_response(:bad_request), + "401": error_response(:unauthorized), + "404": error_response(:not_found), + "422": error_response(:unprocessable_entity) + } + } + }, + "/api/v4/upload/{id}": { + delete: { + operationId: "deleteUpload", + tags: [ "Uploads" ], + summary: "Delete an upload", + description: "Permanently deletes the file and frees the storage it used. Only the key owner's uploads are visible.", + parameters: [ upload_id_parameter ], + responses: { + "200": json_response("The upload was deleted.", "#/components/schemas/DeletedUpload"), + "401": error_response(:unauthorized), + "404": error_response(:not_found) + } + } + }, + "/api/v4/uploads/batch": { + delete: { + operationId: "deleteUploadsBatch", + tags: [ "Uploads" ], + summary: "Delete several uploads", + description: "Deletes many uploads at once. IDs that do not belong to the key owner are reported in `not_found` rather than failing the request.", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: [ "ids" ], + properties: { + ids: { + type: "array", + minItems: 1, + items: { type: "string", format: "uuid" }, + description: "Upload IDs to delete." + } + } + } + } + } + }, + responses: { + "200": json_response("Deletion results.", "#/components/schemas/BatchDeleteResult"), + "400": error_response(:bad_request), + "401": error_response(:unauthorized) + } + } + }, + "/api/v4/revoke": { + post: { + operationId: "revokeCurrentApiKey", + tags: [ "API keys" ], + summary: "Revoke the API key making the request", + description: "Immediately and irreversibly revokes the key used to authenticate this request. Uploads are not deleted.", + responses: { + "200": json_response("The key was revoked.", "#/components/schemas/RevokedKey"), + "401": error_response(:unauthorized) + } + } + }, + "/{id}/{filename}": { + get: { + operationId: "getFile", + tags: [ "Files" ], + summary: "Fetch an uploaded file", + description: "Public, unauthenticated file delivery. Redirects to the storage host with a one-year cache lifetime. CORS is open to all origins.", + security: [], + parameters: [ + upload_id_parameter, + { + name: "filename", + in: "path", + required: true, + description: "Filename of the upload.", + schema: { type: "string" } + } + ], + responses: { + "302": { + description: "Redirect to the file on the storage host.", + headers: { + Location: { description: "Absolute URL of the file.", schema: { type: "string", format: "uri" } } + } + }, + "404": { description: "No upload with that ID exists." } + } + } + }, + "/rescue": { + get: { + operationId: "rescueByOriginalUrl", + tags: [ "Files" ], + summary: "Look up a file by the URL it was imported from", + description: "Finds a file previously imported with `upload_from_url` and redirects to its CDN URL. Used to repair links to retired hosts.", + security: [], + parameters: [ + { + name: "url", + in: "query", + required: true, + description: "The original URL the file was imported from.", + schema: { type: "string", format: "uri" } + } + ], + responses: { + "302": { + description: "Redirect to the CDN URL of the matching file.", + headers: { + Location: { description: "CDN URL of the file.", schema: { type: "string", format: "uri" } } + } + }, + "400": { + description: "The `url` parameter is missing.", + content: { "application/json": { schema: { "$ref": "#/components/schemas/Error" } } } + }, + "404": { + description: "No file was imported from that URL. Image requests receive a placeholder SVG instead of a JSON body.", + content: { + "application/json": { schema: { "$ref": "#/components/schemas/Error" } }, + "image/svg+xml": { schema: { type: "string" } } + } + } + } + } + } + } + end + + def upload_id_parameter + { + name: "id", + in: "path", + required: true, + description: "Upload ID returned when the file was created.", + schema: { type: "string", format: "uuid" } + } + end + + def json_response(description, schema_ref) + { + description: description, + content: { "application/json": { schema: { "$ref": schema_ref } } } + } + end + + ERROR_RESPONSES = { + bad_request: [ "A required parameter is missing or malformed.", "#/components/schemas/Error" ], + unauthorized: [ "The API key is missing, invalid or revoked.", "#/components/schemas/Error" ], + payment_required: [ "The upload would exceed the account's storage quota.", "#/components/schemas/QuotaError" ], + not_found: [ "No matching resource belongs to the API key's owner.", "#/components/schemas/Error" ], + unprocessable_entity: [ "The request was understood but the file could not be stored.", "#/components/schemas/Error" ] + }.freeze + + def error_response(kind) + description, schema_ref = ERROR_RESPONSES.fetch(kind) + json_response(description, schema_ref) + end + + def components + { + securitySchemes: { + bearerAuth: { + type: "http", + scheme: "bearer", + description: "API key created at #{base_url}/api_keys, sent as `Authorization: Bearer sk_cdn_...`." + } + }, + schemas: { + Upload: { + type: "object", + description: "A stored file.", + required: [ "id", "filename", "size", "content_type", "url", "created_at" ], + properties: { + id: { type: "string", format: "uuid", description: "Upload ID." }, + filename: { type: "string", description: "Filename as stored." }, + size: { type: "integer", description: "Size in bytes." }, + content_type: { type: "string", description: "Detected MIME type." }, + url: { type: "string", format: "uri", description: "Permanent public CDN URL." }, + created_at: { type: "string", format: "date-time", description: "ISO 8601 creation time." } + } + }, + DeletedUpload: { + type: "object", + required: [ "id", "deleted" ], + properties: { + id: { type: "string", format: "uuid" }, + deleted: { type: "boolean", const: true } + } + }, + BatchUploadResult: { + type: "object", + required: [ "uploads", "failed" ], + properties: { + uploads: { type: "array", items: { "$ref": "#/components/schemas/Upload" }, description: "Files that were stored." }, + failed: { + type: "array", + description: "Files that were rejected.", + items: { + type: "object", + required: [ "filename", "reason" ], + properties: { + filename: { type: "string" }, + reason: { type: "string", description: "Why this file was rejected." } + } + } + } + } + }, + BatchDeleteResult: { + type: "object", + required: [ "deleted" ], + properties: { + deleted: { + type: "array", + items: { + type: "object", + required: [ "id", "filename" ], + properties: { + id: { type: "string", format: "uuid" }, + filename: { type: "string" } + } + } + }, + not_found: { + type: "array", + items: { type: "string", format: "uuid" }, + description: "IDs that did not match an upload owned by the key owner. Omitted when every ID matched." + } + } + }, + User: { + type: "object", + required: [ "id", "email", "name", "storage_used", "storage_limit", "quota_tier" ], + properties: { + id: { type: "string", description: "Public user ID." }, + email: { type: "string", format: "email" }, + name: { type: "string" }, + storage_used: { type: "integer", description: "Bytes currently stored." }, + storage_limit: { type: "integer", description: "Bytes allowed." }, + quota_tier: { "$ref": "#/components/schemas/QuotaTier" } + } + }, + QuotaTier: { + type: "string", + description: "Storage tier of the account.", + enum: Quota::ALL_POLICIES.keys.map(&:to_s) + }, + RevokedKey: { + type: "object", + required: [ "success", "owner_email", "key_name", "status" ], + properties: { + success: { type: "boolean", const: true }, + owner_email: { type: "string", format: "email" }, + key_name: { type: "string" }, + status: { type: "string", const: "complete" } + } + }, + Error: { + type: "object", + description: "Every error response uses this shape. Branch on `code`, show `message`, act on `hint`.", + required: [ "error", "code", "message", "status", "documentation_url" ], + properties: { + error: { type: "string", description: "Short error string. Kept for backwards compatibility; prefer `code`." }, + code: { + type: "string", + description: "Stable machine-readable error code.", + enum: %w[ + invalid_auth + missing_parameter + too_many_files + quota_exceeded + file_too_large + upload_failed + rename_failed + upload_not_found + not_found + route_not_found + original_url_not_found + validation_failed + internal_error + ] + }, + message: { type: "string", description: "Human-readable description of what went wrong." }, + hint: { type: "string", description: "How to resolve the error." }, + status: { type: "integer", description: "HTTP status code, repeated for clients that only read the body." }, + documentation_url: { type: "string", format: "uri", description: "Documentation for this API." }, + details: { type: "array", items: { type: "string" }, description: "Field-level validation messages, when applicable." }, + parameter: { type: "string", description: "Name of the offending parameter, when applicable." }, + error_id: { type: "string", description: "Support identifier for server errors." } + } + }, + QuotaError: { + allOf: [ + { "$ref": "#/components/schemas/Error" }, + { + type: "object", + required: [ "quota" ], + properties: { + quota: { + type: "object", + required: [ "storage_used", "storage_limit", "quota_tier", "percentage_used" ], + properties: { + storage_used: { type: "integer", description: "Bytes currently stored." }, + storage_limit: { type: "integer", description: "Bytes allowed." }, + quota_tier: { "$ref": "#/components/schemas/QuotaTier" }, + percentage_used: { type: "number", description: "Percentage of the limit in use." } + } + } + } + } + ] + } + } + } + end + end +end diff --git a/app/views/docs/pages/api.md b/app/views/docs/pages/api.md index 2a239a4..4e3a80c 100644 --- a/app/views/docs/pages/api.md +++ b/app/views/docs/pages/api.md @@ -8,6 +8,20 @@ order: 3 Upload images programmatically using the CDN API. +## Machine-readable spec + +The full API surface is published as an OpenAPI 3.2 document: + +| Format | URL | +|--------|-----| +| JSON | [https://cdn.hackclub.com/openapi.json](/openapi.json) | +| YAML | [https://cdn.hackclub.com/openapi.yaml](/openapi.yaml) | + +It is also served at `/api/openapi.json` and `/api/openapi.yaml`, needs no +authentication, and is linked from every page as +``. Point your client generator, +agent, or API console at it rather than scraping this page. + ## Authentication Create an API key at [API Keys](/api_keys). Keys are shown once, so copy it immediately. @@ -122,6 +136,85 @@ const result = await response.json(); Returns 404 if the upload doesn't exist or doesn't belong to you. +## POST /api/v4/uploads + +Upload up to 40 files in one request. Partial success is normal: stored files come back in `uploads`, rejected ones in `failed`. + +```bash +curl -X POST \ + -H "Authorization: Bearer sk_cdn_your_key_here" \ + -F "files[]=@one.png" \ + -F "files[]=@two.png" \ + https://cdn.hackclub.com/api/v4/uploads +``` + +**Response (201 when at least one file was stored, otherwise 422):** + +```json +{ + "uploads": [ + { + "id": "01234567-89ab-cdef-0123-456789abcdef", + "filename": "one.png", + "size": 12345, + "content_type": "image/png", + "url": "https://cdn.hackclub.com/01234567-89ab-cdef-0123-456789abcdef/one.png", + "created_at": "2026-01-29T12:00:00Z" + } + ], + "failed": [ + { "filename": "two.png", "reason": "File exceeds your per-file limit" } + ] +} +``` + +## PATCH /api/v4/uploads/:id/rename + +Rename an upload. The filename is part of the CDN URL, so the old URL stops resolving — update any references. + +```bash +curl -X PATCH \ + -H "Authorization: Bearer sk_cdn_your_key_here" \ + -H "Content-Type: application/json" \ + -d '{"filename":"better-name.png"}' \ + https://cdn.hackclub.com/api/v4/uploads/01234567-89ab-cdef-0123-456789abcdef/rename +``` + +Responds with the updated upload object. + +## DELETE /api/v4/uploads/batch + +Delete several uploads at once. IDs that aren't yours are reported in `not_found` instead of failing the request. + +```bash +curl -X DELETE \ + -H "Authorization: Bearer sk_cdn_your_key_here" \ + -H "Content-Type: application/json" \ + -d '{"ids":["01234567-89ab-cdef-0123-456789abcdef"]}' \ + https://cdn.hackclub.com/api/v4/uploads/batch +``` + +**Response:** + +```json +{ + "deleted": [ + { "id": "01234567-89ab-cdef-0123-456789abcdef", "filename": "photo.jpg" } + ], + "not_found": ["ffffffff-ffff-ffff-ffff-ffffffffffff"] +} +``` + +## POST /api/v4/revoke + +Revoke the API key making the request. Immediate and irreversible; uploads are untouched. + +```bash +curl -X POST \ + -H "Authorization: Bearer sk_cdn_your_key_here" \ + https://cdn.hackclub.com/api/v4/revoke +``` + ## GET /api/v4/me Get the authenticated user and quota information. @@ -149,36 +242,64 @@ curl -H "Authorization: Bearer sk_cdn_your_key_here" \ ## Errors -| Status | Meaning | -|--------|---------| -| 400 | Missing required parameters | -| 401 | Invalid or missing API key | -| 402 | Storage quota exceeded | -| 404 | Resource not found | -| 422 | Validation failed | - -**Standard error:** +Every error is JSON and follows a predictable shape. For example, a missing file parameter returns 400: ```json { - "error": "Missing file parameter" + "error": "Missing file parameter", + "code": "missing_parameter", + "message": "Required parameter `file` is missing or blank.", + "hint": "Send the file as multipart/form-data under the `file` field, e.g. `curl -F \"file=@photo.jpg\"`.", + "parameter": "file", + "status": 400, + "documentation_url": "https://cdn.hackclub.com/docs/api" } ``` -**Quota error (402):** +| Field | Meaning | +| ----- | ------- | +| `code` | Stable machine-readable identifier. Branch on this. | +| `message` | Human-readable description of what went wrong. | +| `hint` | How to resolve it. | +| `status` | HTTP status, repeated for clients that only read the body. | +| `documentation_url` | Where to read more. | +| `error` | Short legacy string, kept for backwards compatibility. Prefer `code`. | +| `details` | Field-level validation messages, when applicable. | +| `parameter` | The offending parameter, when applicable. | +| `error_id` | Support identifier, on server errors. | + +| Status | Codes | +| ------ | ----- | +| 400 | `missing_parameter`, `too_many_files` | +| 401 | `invalid_auth` | +| 402 | `quota_exceeded`, `file_too_large` | +| 404 | `not_found`, `upload_not_found`, `route_not_found`, `original_url_not_found` | +| 422 | `validation_failed`, `upload_failed`, `rename_failed` | +| 500 | `internal_error` | + +**Quota error (402)** adds a `quota` object: ```json { "error": "Storage quota exceeded", + "code": "quota_exceeded", + "message": "This upload would exceed the storage quota for your account (unverified tier).", + "hint": "Delete files you no longer need, or ask for a higher tier — see https://cdn.hackclub.com/docs/quotas.", "quota": { "storage_used": 52428800, "storage_limit": 52428800, "quota_tier": "unverified", "percentage_used": 100.0 - } + }, + "status": 402, + "documentation_url": "https://cdn.hackclub.com/docs/api" } ``` +Requests to a path that doesn't exist under `/api`, and any request that asks for +JSON with `Accept: application/json`, return `route_not_found` rather than an +HTML 404 page. + See [Storage Quotas](/docs/quotas) for details on getting more space. ## Help diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 8f6880e..3b08c84 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,13 +1,35 @@ - + - <%= content_for(:title) || "CDN" %> + <%= page_title %> <%= csrf_meta_tags %> <%= csp_meta_tag %> + + + + + + + + + + + + + + + + + + + + + + <%= yield :head %> <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %> diff --git a/app/views/static_pages/home.html.erb b/app/views/static_pages/home.html.erb index 9e4e9b7..696550e 100644 --- a/app/views/static_pages/home.html.erb +++ b/app/views/static_pages/home.html.erb @@ -1,3 +1,7 @@ +<% content_for :head do %> + <%= json_ld_tag(home_structured_data) %> +<% end %> + <% if signed_in? %> <%= render Components::StaticPages::Home.new(stats: @user_stats, user: current_user, flavor_text: @flavor_text) %> <% else %> diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index aac4cf4..ba30726 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -19,4 +19,5 @@ inflect.acronym "CDN" inflect.acronym "HCA" inflect.acronym "API" + inflect.acronym "JSON" end diff --git a/config/routes.rb b/config/routes.rb index caff1c8..d699875 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -34,6 +34,9 @@ end end + get "/openapi", to: "open_api#show", as: :openapi, defaults: { format: :json } + get "/api/openapi", to: "open_api#show", as: :api_openapi, defaults: { format: :json } + get "/docs", to: redirect("/docs/getting-started") get "/docs/:id", to: "docs#show", as: :doc # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html @@ -60,4 +63,8 @@ # External upload redirects (must be last to avoid conflicts) match "/:id/*filename", to: "external_uploads#preflight", via: :options, constraints: { id: /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ } get "/:id/*filename", to: "external_uploads#show", constraints: { id: /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ }, as: :external_upload + + match "/api/*path", to: "errors#not_found", via: :all, format: false + match "*path", to: "errors#not_found", via: :all, format: false, + constraints: ->(request) { request.format == :json && !request.path.start_with?("/rails/") } end diff --git a/test/controllers/api/v4/error_responses_test.rb b/test/controllers/api/v4/error_responses_test.rb new file mode 100644 index 0000000..f5bccf5 --- /dev/null +++ b/test/controllers/api/v4/error_responses_test.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require "test_helper" + +class API::V4::ErrorResponsesTest < ActionDispatch::IntegrationTest + setup do + @user = users(:one) + @api_key = @user.api_keys.create!(name: "Test Key") + @token = @api_key.token + @auth = { "Authorization" => "Bearer #{@token}" } + end + + test "missing credentials produce a structured 401" do + get api_v4_me_url + + assert_response :unauthorized + json = assert_error_envelope(code: "invalid_auth", status: 401) + assert_equal "invalid_auth", json["error"] + assert_includes json["hint"], "/api_keys" + end + + test "revoked keys produce a structured 401" do + @api_key.revoke! + + get api_v4_me_url, headers: @auth + + assert_response :unauthorized + assert_error_envelope(code: "invalid_auth", status: 401) + end + + test "missing file parameter names the parameter and keeps the legacy error string" do + post api_v4_upload_url, headers: @auth + + assert_response :bad_request + json = assert_error_envelope(code: "missing_parameter", status: 400) + assert_equal "Missing file parameter", json["error"] + assert_equal "file", json["parameter"] + end + + test "missing url parameter produces a structured 400" do + post api_v4_upload_from_url_url, headers: @auth + + assert_response :bad_request + json = assert_error_envelope(code: "missing_parameter", status: 400) + assert_equal "Missing url parameter", json["error"] + assert_equal "url", json["parameter"] + end + + test "missing files[] parameter produces a structured 400" do + post api_v4_uploads_url, headers: @auth + + assert_response :bad_request + json = assert_error_envelope(code: "missing_parameter", status: 400) + assert_equal "Missing files[] parameter", json["error"] + end + + test "missing ids[] parameter produces a structured 400" do + delete api_v4_uploads_batch_delete_url, headers: @auth + + assert_response :bad_request + json = assert_error_envelope(code: "missing_parameter", status: 400) + assert_equal "Missing ids[] parameter", json["error"] + end + + test "deleting an unknown upload produces a structured 404" do + delete "/api/v4/upload/#{SecureRandom.uuid}", headers: @auth + + assert_response :not_found + json = assert_error_envelope(code: "not_found", status: 404) + assert_equal "Not found", json["error"] + end + + test "renaming an unknown upload produces a structured 404" do + patch api_v4_upload_rename_url(id: SecureRandom.uuid), + params: { filename: "new.png" }, + headers: @auth + + assert_response :not_found + json = assert_error_envelope(code: "upload_not_found", status: 404) + assert_equal "Upload not found", json["error"] + end + + test "renaming without a filename produces a structured 400" do + upload = create_upload + + patch api_v4_upload_rename_url(id: upload["id"]), params: { filename: " " }, headers: @auth + + assert_response :bad_request + json = assert_error_envelope(code: "missing_parameter", status: 400) + assert_equal "Missing filename parameter", json["error"] + assert_equal "filename", json["parameter"] + end + + test "exceeding the per-file size limit produces a structured 402 with quota details" do + stub_max_file_size(1) do + post api_v4_upload_url, + params: { file: fixture_file_upload("test.png", "image/png") }, + headers: @auth + end + + assert_response :payment_required + json = assert_error_envelope(code: "file_too_large", status: 402) + assert_includes json["error"], "File size exceeds your limit" + assert json["quota"]["storage_limit"].present? + assert json["quota"]["quota_tier"].present? + end + + private + + def assert_error_envelope(code:, status:) + assert_equal "application/json", response.media_type + json = JSON.parse(response.body) + + assert_equal code, json["code"] + assert_equal status, json["status"] + assert json["error"].present?, "expected an error string" + assert json["message"].present?, "expected a human-readable message" + assert json["hint"].present?, "expected a resolution hint" + assert_equal "https://cdn.hackclub.com/docs/api", json["documentation_url"] + + json + end + + def create_upload + post api_v4_upload_url, + params: { file: fixture_file_upload("test.png", "image/png") }, + headers: @auth + assert_response :created + JSON.parse(response.body) + end + + def stub_max_file_size(bytes) + policy = QuotaService.new(@user).current_policy + stubbed = Quota::Policy[policy.slug, bytes, policy.max_total_storage] + original = QuotaService.instance_method(:current_policy) + + QuotaService.define_method(:current_policy) { stubbed } + begin + yield + ensure + QuotaService.define_method(:current_policy, original) + end + end +end diff --git a/test/controllers/errors_controller_test.rb b/test/controllers/errors_controller_test.rb new file mode 100644 index 0000000..c1596fa --- /dev/null +++ b/test/controllers/errors_controller_test.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "test_helper" + +class ErrorsControllerTest < ActionDispatch::IntegrationTest + test "unknown API paths return a structured JSON error" do + get "/api/v4/does_not_exist" + + assert_response :not_found + assert_equal "application/json", response.media_type + + json = JSON.parse(response.body) + assert_equal "route_not_found", json["code"] + assert_equal "route_not_found", json["error"] + assert_equal 404, json["status"] + assert_includes json["message"], "/api/v4/does_not_exist" + assert_includes json["message"], "GET" + assert json["hint"].present? + assert_equal "https://cdn.hackclub.com/docs/api", json["documentation_url"] + assert_equal "https://cdn.hackclub.com/openapi.json", json["openapi_url"] + end + + test "unknown API paths return JSON for non-GET verbs too" do + post "/api/v4/nope" + + assert_response :not_found + assert_equal "application/json", response.media_type + json = JSON.parse(response.body) + assert_equal "route_not_found", json["code"] + assert_equal "POST", json["method"] + end + + test "unknown paths return JSON when the client asks for JSON" do + get "/not-a-real-page", headers: { "Accept" => "application/json" } + + assert_response :not_found + assert_equal "application/json", response.media_type + assert_equal "route_not_found", JSON.parse(response.body)["code"] + end + + test "unknown paths still return the HTML 404 page for browsers" do + get "/not-a-real-page", headers: { "Accept" => "text/html" } + + assert_response :not_found + refute_equal "application/json", response.media_type + end + + test "known API endpoints are not swallowed by the catch-all" do + get api_v4_me_url + + assert_response :unauthorized + assert_equal "invalid_auth", JSON.parse(response.body)["code"] + end +end diff --git a/test/controllers/external_uploads_controller_test.rb b/test/controllers/external_uploads_controller_test.rb index 65490c2..f02bf4d 100644 --- a/test/controllers/external_uploads_controller_test.rb +++ b/test/controllers/external_uploads_controller_test.rb @@ -23,4 +23,57 @@ class ExternalUploadsControllerTest < ActionDispatch::IntegrationTest ensure ActionController::Base.allow_forgery_protection = original_forgery_protection end + + test "missing file returns a structured JSON error when JSON is requested" do + get "/#{SecureRandom.uuid}/missing", headers: { "Accept" => "application/json" } + + assert_response :not_found + assert_equal "application/json", response.media_type + json = JSON.parse(response.body) + assert_equal "not_found", json["code"] + assert json["message"].present? + assert json["hint"].present? + assert_equal "https://cdn.hackclub.com/docs/api", json["documentation_url"] + end + + test "missing file still returns a bare 404 for image and browser clients" do + get @file_path + + assert_response :not_found + assert_empty response.body + end + + test "rescue without a url returns a structured JSON error when JSON is requested" do + get rescue_upload_path, headers: { "Accept" => "application/json" } + + assert_response :bad_request + json = JSON.parse(response.body) + assert_equal "missing_parameter", json["code"] + assert_equal "url", json["parameter"] + end + + test "rescue without a url still returns a bare 400 for browsers" do + get rescue_upload_path + + assert_response :bad_request + assert_empty response.body + end + + test "rescue reports an unknown original URL as JSON when JSON is requested" do + get rescue_upload_path(url: "https://example.com/gone.png"), + headers: { "Accept" => "application/json" } + + assert_response :not_found + json = JSON.parse(response.body) + assert_equal "original_url_not_found", json["code"] + assert_equal "https://example.com/gone.png", json["original_url"] + end + + test "rescue still renders the placeholder image for unknown image URLs" do + get rescue_upload_path(url: "https://example.com/gone.png") + + assert_response :success + assert_equal "image/svg+xml", response.media_type + assert_includes response.body, "Original URL not found in CDN" + end end diff --git a/test/controllers/open_api_controller_test.rb b/test/controllers/open_api_controller_test.rb new file mode 100644 index 0000000..265e94d --- /dev/null +++ b/test/controllers/open_api_controller_test.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require "test_helper" + +class OpenAPIControllerTest < ActionDispatch::IntegrationTest + test "serves the spec as JSON without authentication" do + get "/openapi.json" + + assert_response :success + assert_equal "application/json", response.media_type + + spec = JSON.parse(response.body) + assert_equal "3.2.0", spec["openapi"] + assert_equal "Hack Club CDN API", spec.dig("info", "title") + assert spec.dig("info", "version").present? + assert_equal "https://cdn.hackclub.com", spec.dig("servers", 0, "url") + assert_equal "bearer", spec.dig("components", "securitySchemes", "bearerAuth", "scheme") + assert_equal [ { "bearerAuth" => [] } ], spec["security"] + end + + test "spec documents every v4 endpoint" do + get "/openapi.json" + spec = JSON.parse(response.body) + + expected = { + "/api/v4/me" => "get", + "/api/v4/upload" => "post", + "/api/v4/uploads" => "post", + "/api/v4/upload_from_url" => "post", + "/api/v4/uploads/{id}/rename" => "patch", + "/api/v4/upload/{id}" => "delete", + "/api/v4/uploads/batch" => "delete", + "/api/v4/revoke" => "post" + } + + expected.each do |path, verb| + operation = spec.dig("paths", path, verb) + assert operation.present?, "expected #{verb.upcase} #{path} to be documented" + assert operation["operationId"].present?, "#{verb.upcase} #{path} needs an operationId" + assert operation["summary"].present?, "#{verb.upcase} #{path} needs a summary" + assert operation["responses"].present?, "#{verb.upcase} #{path} needs responses" + end + end + + test "every documented path is routable to a real controller action" do + get "/openapi.json" + spec = JSON.parse(response.body) + + spec["paths"].each do |path, operations| + concrete = path.gsub("{id}", SecureRandom.uuid).gsub("{filename}", "file.png") + + operations.each_key do |verb| + recognized = Rails.application.routes.recognize_path(concrete, method: verb.upcase) + refute_equal "errors", recognized[:controller], + "documented #{verb.upcase} #{path} does not match a real route" + end + end + end + + test "every $ref in the spec resolves" do + get "/openapi.json" + spec = JSON.parse(response.body) + + refs(spec).each do |ref| + pointer = ref.delete_prefix("#/").split("/") + assert spec.dig(*pointer).present?, "unresolved $ref: #{ref}" + end + end + + test "documents the error envelope agents branch on" do + get "/openapi.json" + spec = JSON.parse(response.body) + + error = spec.dig("components", "schemas", "Error") + assert_equal %w[error code message status documentation_url], error["required"] + assert_includes error.dig("properties", "code", "enum"), "invalid_auth" + assert_includes error.dig("properties", "code", "enum"), "quota_exceeded" + + unauthorized = spec.dig("paths", "/api/v4/me", "get", "responses", "401") + assert_equal "#/components/schemas/Error", + unauthorized.dig("content", "application/json", "schema", "$ref") + end + + test "serves the spec as YAML" do + get "/openapi.yaml" + + assert_response :success + assert_equal "application/yaml", response.media_type + + spec = YAML.safe_load(response.body) + assert_equal "3.2.0", spec["openapi"] + assert spec.dig("paths", "/api/v4/upload", "post").present? + end + + test "is also reachable under /api and without an extension" do + [ "/openapi", "/api/openapi", "/api/openapi.json" ].each do |path| + get path + + assert_response :success, "expected #{path} to serve the spec" + assert_equal "application/json", response.media_type, "expected #{path} to serve JSON" + assert_equal "3.2.0", JSON.parse(response.body)["openapi"] + end + end + + test "is cacheable and readable cross-origin" do + get "/openapi.json" + + assert_includes response.headers["Cache-Control"], "public" + assert_equal "*", response.headers["Access-Control-Allow-Origin"] + end + + private + + def refs(node) + case node + when Hash + node.flat_map { |key, value| key == "$ref" ? [ value ] : refs(value) } + when Array + node.flat_map { |value| refs(value) } + else + [] + end + end +end diff --git a/test/controllers/static_pages_controller_test.rb b/test/controllers/static_pages_controller_test.rb index bbd5bd9..25409bf 100644 --- a/test/controllers/static_pages_controller_test.rb +++ b/test/controllers/static_pages_controller_test.rb @@ -1,7 +1,60 @@ require "test_helper" class StaticPagesControllerTest < ActionDispatch::IntegrationTest - # test "the truth" do - # assert true - # end + test "homepage renders for signed out visitors" do + get root_url + + assert_response :success + end + + test "homepage exposes the metadata signals agents use for entity resolution" do + get root_url + + assert_select "html[lang=?]", "en" + assert_select "link[rel=canonical][href=?]", "https://cdn.hackclub.com/" + assert_select "meta[property='og:type'][content=?]", "website" + assert_select "meta[property='og:image'][content=?]", "https://cdn.hackclub.com/icon.png" + assert_select "meta[property='og:url'][content=?]", "https://cdn.hackclub.com/" + assert_select "meta[property='og:site_name'][content=?]", "Hack Club CDN" + assert_select "meta[name=description]" + assert_select "title", "Hack Club CDN" + end + + test "homepage links to the machine-readable API description" do + get root_url + + assert_select "link[rel='service-desc'][href=?]", "/openapi.json" + assert_select "link[rel='service-doc'][href=?]", "/docs/api" + end + + test "homepage publishes JSON-LD identifying the app and its publisher" do + get root_url + + scripts = css_select("script[type='application/ld+json']") + assert_equal 1, scripts.size + + data = JSON.parse(scripts.first.text) + assert_equal "https://schema.org", data["@context"] + + by_type = data["@graph"].index_by { |node| node["@type"] } + assert_equal %w[SoftwareApplication Organization WebSite].sort, by_type.keys.sort + + app = by_type["SoftwareApplication"] + assert_equal "Hack Club CDN", app["name"] + assert_equal "https://cdn.hackclub.com", app["url"] + assert app["description"].present? + assert app["offers"].present? + assert_includes app["sameAs"], "https://github.com/hackclub/cdn" + + assert_equal "Hack Club", by_type["Organization"]["name"] + assert by_type["Organization"]["sameAs"].present? + assert_equal "https://cdn.hackclub.com", by_type["WebSite"]["url"] + end + + test "canonical URL reflects the current page, not the request host" do + get doc_url(id: "api") + + assert_response :success + assert_select "link[rel=canonical][href=?]", "https://cdn.hackclub.com/docs/api" + end end From 8a405b34b747f3a49cbdd3d19e787d8bacc10335 Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 1 Sep 2026 16:54:23 -0400 Subject: [PATCH 2/3] rescue --- app/models/open_api_spec.rb | 2 +- config/routes.rb | 2 +- test/controllers/open_api_controller_test.rb | 11 +++++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/app/models/open_api_spec.rb b/app/models/open_api_spec.rb index cb54f21..f5a2ea0 100644 --- a/app/models/open_api_spec.rb +++ b/app/models/open_api_spec.rb @@ -321,7 +321,7 @@ def paths } }, "400": { - description: "The `url` parameter is missing.", + description: "The `url` parameter is missing. Clients that accept JSON receive an error object; other clients receive an empty response body.", content: { "application/json": { schema: { "$ref": "#/components/schemas/Error" } } } }, "404": { diff --git a/config/routes.rb b/config/routes.rb index d699875..0467065 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -64,7 +64,7 @@ match "/:id/*filename", to: "external_uploads#preflight", via: :options, constraints: { id: /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ } get "/:id/*filename", to: "external_uploads#show", constraints: { id: /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ }, as: :external_upload - match "/api/*path", to: "errors#not_found", via: :all, format: false + match "/api(/*path)", to: "errors#not_found", via: :all, format: false match "*path", to: "errors#not_found", via: :all, format: false, constraints: ->(request) { request.format == :json && !request.path.start_with?("/rails/") } end diff --git a/test/controllers/open_api_controller_test.rb b/test/controllers/open_api_controller_test.rb index 265e94d..5659c8d 100644 --- a/test/controllers/open_api_controller_test.rb +++ b/test/controllers/open_api_controller_test.rb @@ -81,6 +81,17 @@ class OpenAPIControllerTest < ActionDispatch::IntegrationTest unauthorized.dig("content", "application/json", "schema", "$ref") end + test "documents both rescue missing-parameter response variants" do + get "/openapi.json" + spec = JSON.parse(response.body) + + bad_request = spec.dig("paths", "/rescue", "get", "responses", "400") + assert_includes bad_request["description"], "accept JSON" + assert_includes bad_request["description"], "empty response body" + assert_equal "#/components/schemas/Error", + bad_request.dig("content", "application/json", "schema", "$ref") + end + test "serves the spec as YAML" do get "/openapi.yaml" From 6a4d79f99999bcc1221a5e858e106df5da8ac717 Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 1 Sep 2026 17:09:15 -0400 Subject: [PATCH 3/3] fix bad params --- .../api/v4/application_controller.rb | 11 ++++++++++ app/controllers/api/v4/uploads_controller.rb | 21 ++++++++++++++----- .../external_uploads_controller.rb | 2 +- app/models/open_api_spec.rb | 11 ++++++---- .../api/v4/error_responses_test.rb | 15 +++++++++++-- .../api/v4/uploads_controller_test.rb | 15 ++++++++++++- .../external_uploads_controller_test.rb | 2 +- 7 files changed, 63 insertions(+), 14 deletions(-) diff --git a/app/controllers/api/v4/application_controller.rb b/app/controllers/api/v4/application_controller.rb index 8d8c5ba..5d88b7f 100644 --- a/app/controllers/api/v4/application_controller.rb +++ b/app/controllers/api/v4/application_controller.rb @@ -12,6 +12,7 @@ class ApplicationController < ActionController::API rescue_from StandardError, with: :handle_error rescue_from ActiveRecord::RecordNotFound, with: :not_found rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity + rescue_from ActionDispatch::Http::Parameters::ParseError, with: :malformed_body private @@ -47,6 +48,16 @@ def not_found ) end + def malformed_body(exception) + render_json_error( + error: "Malformed request body", + code: :malformed_request, + status: :bad_request, + message: "The request body could not be parsed as #{request.media_type.presence || 'the declared content type'}: #{exception.message}", + hint: "Send valid JSON with `Content-Type: application/json`, or drop the header if the request has no body." + ) + end + def unprocessable_entity(exception) render_json_error( error: "Validation failed", diff --git a/app/controllers/api/v4/uploads_controller.rb b/app/controllers/api/v4/uploads_controller.rb index d0c8cff..424a7e6 100644 --- a/app/controllers/api/v4/uploads_controller.rb +++ b/app/controllers/api/v4/uploads_controller.rb @@ -48,13 +48,13 @@ def create # POST /api/v4/uploads (batch) def create_batch - files = params[:files] + files = batch_files - unless files.present? && files.is_a?(Array) + if files.empty? render_missing_parameter( - "files[]", - error: "Missing files[] parameter", - hint: "Send each file as multipart/form-data under the repeated `files[]` field, e.g. `curl -F \"files[]=@a.png\" -F \"files[]=@b.png\"`." + "files", + error: "Missing files parameter", + hint: "Send each file as multipart/form-data under a repeated `files` (or `files[]`) field, e.g. `curl -F \"files=@a.png\" -F \"files=@b.png\"`." ) return end @@ -191,6 +191,17 @@ def destroy_batch private + # Accepts every shape a multipart client might use for the batch field: + # repeated `files`, repeated `files[]`, or a single `files` part. Generated + # OpenAPI clients send the property name verbatim, so `files` must work. + def batch_files + raw = params[:files] + raw = params["files[]"] if raw.blank? + + files = raw.is_a?(Array) ? raw : [ raw ] + files.reject(&:blank?) + end + def render_missing_parameter(name, error:, hint:) render_json_error( error: error, diff --git a/app/controllers/external_uploads_controller.rb b/app/controllers/external_uploads_controller.rb index 3b29842..f1cec6e 100644 --- a/app/controllers/external_uploads_controller.rb +++ b/app/controllers/external_uploads_controller.rb @@ -99,6 +99,6 @@ def render_error_image SVG - render inline: svg, content_type: "image/svg+xml" + render inline: svg, content_type: "image/svg+xml", status: :not_found end end diff --git a/app/models/open_api_spec.rb b/app/models/open_api_spec.rb index f5a2ea0..71b6752 100644 --- a/app/models/open_api_spec.rb +++ b/app/models/open_api_spec.rb @@ -115,22 +115,25 @@ def paths operationId: "createUploadsBatch", tags: [ "Uploads" ], summary: "Upload up to 40 files in one request", - description: "Uploads several files as multipart/form-data. Partial success is normal: successful files are listed in `uploads`, rejected ones in `failed`. Returns 201 when at least one file succeeded, otherwise 422.", + description: "Uploads several files as multipart/form-data under the repeated field `files[]`. The bracketed name is what the server parses as an array, so it is the schema property name too. Partial success is normal: successful files are listed in `uploads`, rejected ones in `failed`. Returns 201 when at least one file succeeded, otherwise 422.", requestBody: { required: true, content: { "multipart/form-data": { schema: { type: "object", - required: [ "files" ], + required: [ "files[]" ], properties: { - files: { + "files[]": { type: "array", maxItems: BatchUploadService::MAX_FILES_PER_BATCH, - description: "Repeated `files[]` fields, at most #{BatchUploadService::MAX_FILES_PER_BATCH} per request.", + description: "Repeated `files[]` fields, at most #{BatchUploadService::MAX_FILES_PER_BATCH} per request. A single unbracketed `files` part is also accepted.", items: { type: "string", format: "binary" } } } + }, + encoding: { + "files[]": { contentType: "application/octet-stream" } } } } diff --git a/test/controllers/api/v4/error_responses_test.rb b/test/controllers/api/v4/error_responses_test.rb index f5bccf5..598eed1 100644 --- a/test/controllers/api/v4/error_responses_test.rb +++ b/test/controllers/api/v4/error_responses_test.rb @@ -46,12 +46,23 @@ class API::V4::ErrorResponsesTest < ActionDispatch::IntegrationTest assert_equal "url", json["parameter"] end - test "missing files[] parameter produces a structured 400" do + test "missing files parameter produces a structured 400" do post api_v4_uploads_url, headers: @auth assert_response :bad_request json = assert_error_envelope(code: "missing_parameter", status: 400) - assert_equal "Missing files[] parameter", json["error"] + assert_equal "Missing files parameter", json["error"] + assert_equal "files", json["parameter"] + end + + test "a malformed JSON body produces a structured 400 instead of a 500" do + post api_v4_upload_from_url_url, + params: '{"url": ', + headers: @auth.merge("Content-Type" => "application/json") + + assert_response :bad_request + json = assert_error_envelope(code: "malformed_request", status: 400) + assert_equal "Malformed request body", json["error"] end test "missing ids[] parameter produces a structured 400" do diff --git a/test/controllers/api/v4/uploads_controller_test.rb b/test/controllers/api/v4/uploads_controller_test.rb index e9ec798..46efc5d 100644 --- a/test/controllers/api/v4/uploads_controller_test.rb +++ b/test/controllers/api/v4/uploads_controller_test.rb @@ -144,7 +144,20 @@ class API::V4::UploadsControllerTest < ActionDispatch::IntegrationTest post api_v4_uploads_url, headers: { "Authorization" => "Bearer #{@token}" } assert_response :bad_request - assert_equal "Missing files[] parameter", JSON.parse(response.body)["error"] + assert_equal "Missing files parameter", JSON.parse(response.body)["error"] + end + + test "should accept a single unbracketed files part" do + assert_difference("Upload.count", 1) do + post api_v4_uploads_url, + params: { files: fixture_file_upload("test.png", "image/png") }, + headers: { "Authorization" => "Bearer #{@token}" } + end + + assert_response :created + json = JSON.parse(response.body) + assert_equal 1, json["uploads"].size + assert_empty json["failed"] end test "should reject a batch over the file count limit before uploading anything" do diff --git a/test/controllers/external_uploads_controller_test.rb b/test/controllers/external_uploads_controller_test.rb index f02bf4d..fba2ac2 100644 --- a/test/controllers/external_uploads_controller_test.rb +++ b/test/controllers/external_uploads_controller_test.rb @@ -72,7 +72,7 @@ class ExternalUploadsControllerTest < ActionDispatch::IntegrationTest test "rescue still renders the placeholder image for unknown image URLs" do get rescue_upload_path(url: "https://example.com/gone.png") - assert_response :success + assert_response :not_found assert_equal "image/svg+xml", response.media_type assert_includes response.body, "Original URL not found in CDN" end