Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
46 changes: 40 additions & 6 deletions app/controllers/api/v4/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@ 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
rescue_from ActionDispatch::Http::Parameters::ParseError, with: :malformed_body

private

Expand All @@ -20,7 +22,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
Expand All @@ -32,21 +39,48 @@ 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 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: {
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
Expand Down
118 changes: 98 additions & 20 deletions app/controllers/api/v4/uploads_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -33,23 +37,37 @@ 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)
def create_batch
files = params[:files]

unless files.present? && files.is_a?(Array)
render json: { error: "Missing files[] parameter" }, status: :bad_request
files = batch_files

if files.empty?
render_missing_parameter(
"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

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

Expand All @@ -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

Expand All @@ -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
Expand All @@ -107,24 +135,44 @@ 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
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\":[\"<upload-id>\",\"<upload-id>\"]}."
)
return
end

Expand All @@ -143,6 +191,28 @@ 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,
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?
Expand All @@ -153,31 +223,39 @@ 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
# For URL uploads, quota is checked after download in create_from_url
# 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)
Expand Down
46 changes: 46 additions & 0 deletions app/controllers/concerns/json_error_responses.rb
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions app/controllers/errors_controller.rb
Original file line number Diff line number Diff line change
@@ -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
Loading