From 5846407c1432301368fc36de5e931915c84324d4 Mon Sep 17 00:00:00 2001 From: 24c02 <163450896+24c02@users.noreply.github.com> Date: Thu, 29 Jan 2026 16:24:14 -0500 Subject: [PATCH] api? --- app/components/admin/users/show.rb | 34 +++ app/components/api_keys/_row.rb | 65 ++++ app/components/api_keys/index.rb | 121 ++++++++ app/components/header_bar.rb | 1 + app/controllers/admin/api_keys_controller.rb | 12 + .../api/v4/application_controller.rb | 39 +++ app/controllers/api/v4/uploads_controller.rb | 58 ++++ app/controllers/api/v4/users_controller.rb | 15 + app/controllers/api_keys_controller.rb | 36 +++ app/models/api_key.rb | 45 +++ app/models/upload.rb | 2 + app/models/user.rb | 1 + app/policies/api_key_policy.rb | 16 + app/views/api_keys/index.html.erb | 4 + app/views/docs/pages/api.md | 277 ++++++++++++++++++ config/initializers/blind_index.rb | 3 + config/initializers/lockbox.rb | 3 + config/routes.rb | 11 + .../api/v4/uploads_controller_test.rb | 98 +++++++ .../api/v4/users_controller_test.rb | 47 +++ test/controllers/api_keys_controller_test.rb | 56 ++++ test/fixtures/api_keys.yml | 17 ++ test/fixtures/files/test.png | Bin 0 -> 5182 bytes test/models/api_key_test.rb | 72 +++++ 24 files changed, 1033 insertions(+) create mode 100644 app/components/api_keys/_row.rb create mode 100644 app/components/api_keys/index.rb create mode 100644 app/controllers/admin/api_keys_controller.rb create mode 100644 app/controllers/api/v4/application_controller.rb create mode 100644 app/controllers/api/v4/uploads_controller.rb create mode 100644 app/controllers/api/v4/users_controller.rb create mode 100644 app/controllers/api_keys_controller.rb create mode 100644 app/models/api_key.rb create mode 100644 app/policies/api_key_policy.rb create mode 100644 app/views/api_keys/index.html.erb create mode 100644 app/views/docs/pages/api.md create mode 100644 config/initializers/blind_index.rb create mode 100644 config/initializers/lockbox.rb create mode 100644 test/controllers/api/v4/uploads_controller_test.rb create mode 100644 test/controllers/api/v4/users_controller_test.rb create mode 100644 test/controllers/api_keys_controller_test.rb create mode 100644 test/fixtures/api_keys.yml create mode 100644 test/fixtures/files/test.png create mode 100644 test/models/api_key_test.rb diff --git a/app/components/admin/users/show.rb b/app/components/admin/users/show.rb index 5de5019..96d812b 100644 --- a/app/components/admin/users/show.rb +++ b/app/components/admin/users/show.rb @@ -2,6 +2,7 @@ class Components::Admin::Users::Show < Components::Base include Phlex::Rails::Helpers::LinkTo + include Phlex::Rails::Helpers::ButtonTo def initialize(user:) @user = user @@ -11,6 +12,7 @@ class Components::Admin::Users::Show < Components::Base div(style: "max-width: 800px; margin: 0 auto; padding: 24px;") do header_section stats_section + api_keys_section uploads_section end end @@ -61,6 +63,38 @@ class Components::Admin::Users::Show < Components::Base end end + def api_keys_section + api_keys = @user.api_keys.recent + return if api_keys.empty? + + div(style: "margin-bottom: 24px;") do + h2(style: "font-size: 1.25rem; font-weight: 600; margin-bottom: 12px;") { "API Keys" } + div(style: "background: var(--bgColor-default, #fff); border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px; overflow: hidden;") do + api_keys.each do |api_key| + api_key_row(api_key) + end + end + end + end + + def api_key_row(api_key) + div(style: "display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--borderColor-default, #d0d7de);") do + div do + div(style: "font-weight: 500;") { api_key.name } + code(style: "font-size: 12px; color: var(--fgColor-muted);") { api_key.masked_token } + end + div(style: "display: flex; align-items: center; gap: 12px;") do + if api_key.revoked? + span(style: "background: #cf222e; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px;") { "REVOKED" } + else + span(style: "background: #1a7f37; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px;") { "ACTIVE" } + button_to "Revoke", helpers.admin_api_key_path(api_key), method: :delete, class: "btn btn-sm btn-danger", data: { confirm: "Revoke this API key?" } + end + span(style: "font-size: 12px; color: var(--fgColor-muted);") { api_key.created_at.strftime("%b %d, %Y") } + end + end + end + def uploads_section uploads = @user.uploads.includes(:blob).order(created_at: :desc).limit(20) return if uploads.empty? diff --git a/app/components/api_keys/_row.rb b/app/components/api_keys/_row.rb new file mode 100644 index 0000000..dc4418e --- /dev/null +++ b/app/components/api_keys/_row.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +class Components::APIKeys::Row < Components::Base + include Phlex::Rails::Helpers::FormWith + + def initialize(api_key:, index: 0) + @api_key = api_key + @index = index + end + + def view_template + div( + style: "padding: 16px; #{index > 0 ? 'border-top: 1px solid var(--borderColor-default, #d0d7de);' : ''}", + data: { api_key_id: api_key.id } + ) do + div(style: "display: flex; justify-content: space-between; align-items: flex-start; gap: 16px;") do + div(style: "flex: 1; min-width: 0;") do + div(style: "display: flex; align-items: center; gap: 8px; margin-bottom: 8px;") do + render Primer::Beta::Octicon.new(icon: :key, size: :small) + div(style: "font-size: 14px; font-weight: 500;") do + plain api_key.name + end + end + div(style: "font-size: 12px; color: var(--fgColor-muted, #656d76); font-family: monospace;") do + plain api_key.masked_token + end + div(style: "font-size: 12px; color: var(--fgColor-muted, #656d76); margin-top: 4px;") do + plain "Created #{time_ago_in_words(api_key.created_at)} ago" + end + end + + render_revoke_dialog + end + end + end + + private + + attr_reader :api_key, :index + + def render_revoke_dialog + render Primer::Alpha::Dialog.new(title: "Revoke API key?", size: :medium) do |dialog| + dialog.with_show_button(scheme: :danger, size: :small) do + render Primer::Beta::Octicon.new(icon: :trash) + end + dialog.with_header(variant: :large) do + h1(style: "margin: 0;") { "Revoke \"#{api_key.name}\"?" } + end + dialog.with_body do + p(style: "margin: 0;") do + plain "This action cannot be undone. Any applications using this API key will immediately lose access." + end + end + dialog.with_footer do + div(style: "display: flex; justify-content: flex-end; gap: 8px;") do + form_with url: api_key_path(api_key), method: :delete, style: "display: inline;" do + button(type: "submit", class: "btn btn-danger") do + plain "Revoke key" + end + end + end + end + end + end +end diff --git a/app/components/api_keys/index.rb b/app/components/api_keys/index.rb new file mode 100644 index 0000000..c31b143 --- /dev/null +++ b/app/components/api_keys/index.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +class Components::APIKeys::Index < Components::Base + include Phlex::Rails::Helpers::FormWith + + def initialize(api_keys:, new_token: nil) + @api_keys = api_keys + @new_token = new_token + end + + def view_template + div(style: "max-width: 1200px; margin: 0 auto; padding: 24px;") do + header_section + new_token_alert if new_token + create_form + api_keys_list + end + end + + private + + attr_reader :api_keys, :new_token + + def header_section + header(style: "margin-bottom: 24px;") do + h1(style: "font-size: 2rem; font-weight: 600; margin: 0;") { "API Keys" } + p(style: "color: var(--fgColor-muted, #656d76); margin: 8px 0 0; font-size: 14px;") do + plain "Manage your API keys for programmatic access. " + a(href: "/docs/api", style: "color: var(--fgColor-accent, #0969da);") { "View API documentation" } + end + end + end + + def new_token_alert + div( + style: "background: var(--bgColor-success-muted, #dafbe1); border: 1px solid var(--borderColor-success-emphasis, #1a7f37); border-radius: 6px; padding: 16px; margin-bottom: 24px;" + ) do + div(style: "display: flex; align-items: flex-start; gap: 12px;") do + render Primer::Beta::Octicon.new(icon: :check, size: :medium, color: :success) + div(style: "flex: 1;") do + p(style: "margin: 0 0 8px; font-weight: 600; color: var(--fgColor-success, #1a7f37);") do + plain "API key created successfully!" + end + p(style: "margin: 0 0 8px; color: var(--fgColor-default, #1f2328); font-size: 14px;") do + plain "Copy your API key now. You won't be able to see it again!" + end + div(style: "background: var(--bgColor-default, #fff); border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px; padding: 12px; font-family: monospace; font-size: 14px; word-break: break-all;") do + plain new_token + end + end + end + end + end + + def create_form + div(style: "background: linear-gradient(135deg, var(--bgColor-default, #fff) 0%, var(--bgColor-muted, #f6f8fa) 100%); border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 12px; overflow: hidden; margin-bottom: 24px;") do + # Header with icon + div(style: "padding: 20px 24px; border-bottom: 1px solid var(--borderColor-muted, #d0d7de); display: flex; align-items: center; gap: 12px;") do + div(style: "width: 40px; height: 40px; border-radius: 10px; background: linear-gradient(135deg, #0969da 0%, #0550ae 100%); display: flex; align-items: center; justify-content: center; color: #fff;") do + render Primer::Beta::Octicon.new(icon: :key, size: :small) + end + div do + h2(style: "font-size: 1.125rem; font-weight: 600; margin: 0; color: var(--fgColor-default, #1f2328);") { "Create new API key" } + p(style: "font-size: 13px; color: var(--fgColor-muted, #656d76); margin: 2px 0 0;") { "Generate a key to access the CDN API programmatically" } + end + end + + # Form body + div(style: "padding: 24px;") do + form_with url: api_keys_path, method: :post do + div(style: "margin-bottom: 20px;") do + label(for: "api_key_name", style: "display: block; font-size: 14px; font-weight: 600; margin-bottom: 8px; color: var(--fgColor-default, #1f2328);") do + plain "Key name" + end + p(style: "font-size: 12px; color: var(--fgColor-muted, #656d76); margin: 0 0 8px;") do + plain "Give your key a memorable name so you can identify it later" + end + input( + type: "text", + name: "api_key[name]", + id: "api_key_name", + placeholder: "e.g., Production server, CI/CD pipeline, Local dev", + required: true, + class: "form-control", + style: "max-width: 400px; padding: 10px 12px; font-size: 14px;" + ) + end + + button(type: "submit", class: "btn btn-primary", style: "padding: 10px 20px; font-size: 14px; font-weight: 600;") do + render Primer::Beta::Octicon.new(icon: :plus, mr: 2) + plain "Generate API key" + end + end + end + end + end + + def api_keys_list + div do + h2(style: "font-size: 1.25rem; font-weight: 600; margin: 0 0 16px;") { "Your API keys" } + + if api_keys.any? + div(style: "background: var(--bgColor-default, #fff); border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px; overflow: hidden;") do + api_keys.each_with_index do |api_key, index| + render Components::APIKeys::Row.new(api_key: api_key, index: index) + end + end + else + empty_state + end + end + end + + def empty_state + div(style: "text-align: center; padding: 64px 24px; color: var(--fgColor-muted, #656d76); border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px;") do + render Primer::Beta::Octicon.new(icon: :key, size: :medium) + h3(style: "font-size: 20px; font-weight: 600; margin: 16px 0 8px;") { "No API keys yet" } + p(style: "margin: 0;") { "Create your first API key to get started with the API" } + end + end +end diff --git a/app/components/header_bar.rb b/app/components/header_bar.rb index 6d29a99..7ad21fe 100644 --- a/app/components/header_bar.rb +++ b/app/components/header_bar.rb @@ -14,6 +14,7 @@ class Components::HeaderBar < Components::Base nav(style: "display: flex; align-items: center; gap: 1rem; margin-left: 1rem;") do if signed_in? a(href: uploads_path, style: "color: var(--fgColor-default); text-decoration: none; font-size: 14px;") { "Uploads" } + a(href: api_keys_path, style: "color: var(--fgColor-default); text-decoration: none; font-size: 14px;") { "API Keys" } end a(href: doc_path("getting-started"), style: "color: var(--fgColor-default); text-decoration: none; font-size: 14px;") { "Docs" } admin_tool(element: "span") do diff --git a/app/controllers/admin/api_keys_controller.rb b/app/controllers/admin/api_keys_controller.rb new file mode 100644 index 0000000..d8e1db3 --- /dev/null +++ b/app/controllers/admin/api_keys_controller.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Admin + class APIKeysController < ApplicationController + def destroy + api_key = APIKey.find(params[:id]) + user = api_key.user + api_key.revoke! + redirect_to admin_user_path(user), notice: "API key '#{api_key.name}' revoked." + end + end +end diff --git a/app/controllers/api/v4/application_controller.rb b/app/controllers/api/v4/application_controller.rb new file mode 100644 index 0000000..a231397 --- /dev/null +++ b/app/controllers/api/v4/application_controller.rb @@ -0,0 +1,39 @@ +module API + module V4 + class ApplicationController < ActionController::API + include ActionController::HttpAuthentication::Token::ControllerMethods + + attr_reader :current_user, :current_token + + before_action :authenticate! + + rescue_from ActiveRecord::RecordNotFound, with: :not_found + rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity + + private + + def authenticate! + @current_token = authenticate_with_http_token do |token, _options| + APIKey.find_by_token(token) + end + + unless @current_token&.active? + return render json: { error: "invalid_auth" }, status: :unauthorized + end + + @current_user = @current_token.user + end + + def not_found + render json: { error: "Not found" }, status: :not_found + end + + def unprocessable_entity(exception) + render json: { + error: "Validation failed", + details: exception.record.errors.full_messages + }, status: :unprocessable_entity + end + end + end +end \ No newline at end of file diff --git a/app/controllers/api/v4/uploads_controller.rb b/app/controllers/api/v4/uploads_controller.rb new file mode 100644 index 0000000..ffb24d4 --- /dev/null +++ b/app/controllers/api/v4/uploads_controller.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module API + module V4 + class UploadsController < ApplicationController + # POST /api/v4/upload + def create + file = params[:file] + + unless file.present? + render json: { error: "Missing file parameter" }, status: :bad_request + return + end + + blob = ActiveStorage::Blob.create_and_upload!( + io: file.tempfile, + filename: file.original_filename, + content_type: file.content_type + ) + + upload = current_user.uploads.create!(blob: blob, provenance: :api) + + render json: upload_json(upload), status: :created + rescue => e + render json: { error: "Upload failed: #{e.message}" }, status: :unprocessable_entity + end + + # POST /api/v4/upload_from_url + def create_from_url + url = params[:url] + + unless url.present? + render json: { error: "Missing url parameter" }, status: :bad_request + return + end + + upload = Upload.create_from_url(url, user: current_user, provenance: :api, original_url: url) + + render json: upload_json(upload), status: :created + rescue => e + render json: { error: "Upload failed: #{e.message}" }, status: :unprocessable_entity + end + + private + + def upload_json(upload) + { + id: upload.id, + filename: upload.filename.to_s, + size: upload.byte_size, + content_type: upload.content_type, + url: upload.cdn_url, + created_at: upload.created_at.iso8601 + } + end + end + end +end diff --git a/app/controllers/api/v4/users_controller.rb b/app/controllers/api/v4/users_controller.rb new file mode 100644 index 0000000..1920e32 --- /dev/null +++ b/app/controllers/api/v4/users_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module API + module V4 + class UsersController < ApplicationController + def show + render json: { + id: current_user.public_id, + email: current_user.email, + name: current_user.name + } + end + end + end +end diff --git a/app/controllers/api_keys_controller.rb b/app/controllers/api_keys_controller.rb new file mode 100644 index 0000000..517592a --- /dev/null +++ b/app/controllers/api_keys_controller.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class APIKeysController < ApplicationController + before_action :set_api_key, only: [:destroy] + + def index + @api_keys = current_user.api_keys.active.recent + end + + def create + @api_key = current_user.api_keys.create!(api_key_params) + + flash[:api_key_token] = @api_key.token + redirect_to api_keys_path, notice: "API key created. Copy it now - you won't see it again!" + rescue ActiveRecord::RecordInvalid => e + redirect_to api_keys_path, alert: "Failed to create API key: #{e.message}" + end + + def destroy + authorize @api_key, :destroy? + @api_key.revoke! + redirect_to api_keys_path, notice: "API key revoked successfully." + rescue Pundit::NotAuthorizedError + redirect_to api_keys_path, alert: "You are not authorized to revoke this API key." + end + + private + + def set_api_key + @api_key = APIKey.find(params[:id]) + end + + def api_key_params + params.require(:api_key).permit(:name) + end +end diff --git a/app/models/api_key.rb b/app/models/api_key.rb new file mode 100644 index 0000000..b8db22c --- /dev/null +++ b/app/models/api_key.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class APIKey < ApplicationRecord + belongs_to :user + + # Lockbox encryption + has_encrypted :token + + # Blind index for token lookup + blind_index :token + + before_validation :generate_token, on: :create + + validates :name, presence: true, length: { maximum: 255 } + + scope :active, -> { where(revoked: false) } + scope :recent, -> { order(created_at: :desc) } + + # Find by token using blind index + def self.find_by_token(token) + find_by(token: token) # Blind index handles lookup + end + + def revoke! + update!(revoked: true, revoked_at: Time.current) + end + + def active? + !revoked + end + + def masked_token + # Decrypt to get the full token, then mask it + full = token + prefix = full[0...13] # "sk_cdn_" + first 6 chars + suffix = full[-6..] # Last 6 chars + "#{prefix}....#{suffix}" + end + + private + + def generate_token + self.token ||= "sk_cdn_#{SecureRandom.hex(32)}" + end +end diff --git a/app/models/upload.rb b/app/models/upload.rb index 1e6aee5..a13477a 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'open-uri' + class Upload < ApplicationRecord include PgSearch::Model diff --git a/app/models/user.rb b/app/models/user.rb index 9336bcb..996421b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -16,6 +16,7 @@ class User < ApplicationRecord encrypts :hca_access_token has_many :uploads, dependent: :destroy + has_many :api_keys, dependent: :destroy, class_name: 'APIKey' def self.find_or_create_from_omniauth(auth) hca_id = auth.uid diff --git a/app/policies/api_key_policy.rb b/app/policies/api_key_policy.rb new file mode 100644 index 0000000..f1c4945 --- /dev/null +++ b/app/policies/api_key_policy.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class APIKeyPolicy < ApplicationPolicy + def index? = true + def create? = true + + def destroy? + user.is_admin? || record.user_id == user.id + end + + class Scope < ApplicationPolicy::Scope + def resolve + user.is_admin? ? scope.all : scope.where(user: user) + end + end +end diff --git a/app/views/api_keys/index.html.erb b/app/views/api_keys/index.html.erb new file mode 100644 index 0000000..9928d57 --- /dev/null +++ b/app/views/api_keys/index.html.erb @@ -0,0 +1,4 @@ +<%= render Components::APIKeys::Index.new( + api_keys: @api_keys, + new_token: flash[:api_key_token] +) %> diff --git a/app/views/docs/pages/api.md b/app/views/docs/pages/api.md new file mode 100644 index 0000000..9f13898 --- /dev/null +++ b/app/views/docs/pages/api.md @@ -0,0 +1,277 @@ +--- +title: API Documentation +icon: code +order: 3 +--- + +# API Documentation 🔧 + +Want to upload files programmatically? You've come to the right place! Our API lets you integrate CDN uploads directly into your apps. + +## Authentication + +First, you'll need an API key! Head over to [API Keys](/api_keys) to create one. + +Your API key will look something like this: + +``` +sk_cdn_a1b2c3d4e5f6... +``` + +**Important**: Copy it immediately after creation—you won't be able to see it again. + +### Using Your API Key + +Include your API key in the `Authorization` header with the `Bearer` prefix: + +```bash +Authorization: Bearer sk_cdn_your_key_here +``` + +## Endpoints + +### GET /api/v4/me + +Get information about the currently authenticated user! + +**Response:** + +```json +{ + "id": "usr_abc123", + "email": "cat@hackclub.com", + "name": "Cool Cat" +} +``` + +**Examples:** + +#### cURL + +```bash +curl -H "Authorization: Bearer sk_cdn_your_key_here" \ + https://cdn.hackclub.com/api/v4/me +``` + +#### JavaScript + +```javascript +const response = await fetch('https://cdn.hackclub.com/api/v4/me', { + headers: { + 'Authorization': 'Bearer sk_cdn_your_key_here' + } +}); + +const user = await response.json(); +console.log(user); +``` + +#### Ruby + +```ruby +require 'faraday' +require 'json' + +conn = Faraday.new(url: 'https://cdn.hackclub.com') +response = conn.get('/api/v4/me') do |req| + req.headers['Authorization'] = 'Bearer sk_cdn_your_key_here' +end + +user = JSON.parse(response.body) +puts user +``` + +--- + +### POST /api/v4/upload + +Upload a file directly! This endpoint accepts multipart form data. + +**Parameters:** + +- `file` (required): The file to upload + +**Response:** + +```json +{ + "id": "01234567-89ab-cdef-0123-456789abcdef", + "filename": "cat.png", + "size": 12345, + "content_type": "image/png", + "url": "https://cdn.hackclub.com/01234567-89ab-cdef-0123-456789abcdef/cat.png", + "created_at": "2026-01-29T12:00:00Z" +} +``` + +**Examples:** + +#### cURL + +```bash +curl -X POST \ + -H "Authorization: Bearer sk_cdn_your_key_here" \ + -F "file=@/path/to/cat.png" \ + https://cdn.hackclub.com/api/v4/upload +``` + +#### JavaScript + +```javascript +const formData = new FormData(); +formData.append('file', fileInput.files[0]); + +const response = await fetch('https://cdn.hackclub.com/api/v4/upload', { + method: 'POST', + headers: { + 'Authorization': 'Bearer sk_cdn_your_key_here' + }, + body: formData +}); + +const upload = await response.json(); +console.log('Uploaded to:', upload.url); +``` + +#### Ruby + +```ruby +require 'faraday' +require 'faraday/multipart' +require 'json' + +conn = Faraday.new(url: 'https://cdn.hackclub.com') do |f| + f.request :multipart + f.adapter Faraday.default_adapter +end + +response = conn.post('/api/v4/upload') do |req| + req.headers['Authorization'] = 'Bearer sk_cdn_your_key_here' + req.body = { + file: Faraday::Multipart::FilePart.new( + '/path/to/cat.png', + 'image/png' + ) + } +end + +upload = JSON.parse(response.body) +puts "Uploaded to: #{upload['url']}" +``` + +--- + +### POST /api/v4/upload\_from\_url + +Upload a file from a URL. Perfect for grabbing images from the internet. + +**Parameters:** + +- `url` (required): The URL of the file to upload + +**Response:** + +```json +{ + "id": "01234567-89ab-cdef-0123-456789abcdef", + "filename": "image.jpg", + "size": 54321, + "content_type": "image/jpeg", + "url": "https://cdn.hackclub.com/01234567-89ab-cdef-0123-456789abcdef/image.jpg", + "created_at": "2026-01-29T12:00:00Z" +} +``` + +**Examples:** + +#### cURL + +```bash +curl -X POST \ + -H "Authorization: Bearer sk_cdn_your_key_here" \ + -H "Content-Type: application/json" \ + -d '{"url":"https://example.com/cat.jpg"}' \ + https://cdn.hackclub.com/api/v4/upload_from_url +``` + +#### JavaScript + +```javascript +const response = await fetch('https://cdn.hackclub.com/api/v4/upload_from_url', { + method: 'POST', + headers: { + 'Authorization': 'Bearer sk_cdn_your_key_here', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + url: 'https://example.com/cat.jpg' + }) +}); + +const upload = await response.json(); +console.log('Uploaded to:', upload.url); +``` + +#### Ruby + +```ruby +require 'faraday' +require 'json' + +conn = Faraday.new(url: 'https://cdn.hackclub.com') +response = conn.post('/api/v4/upload_from_url') do |req| + req.headers['Authorization'] = 'Bearer sk_cdn_your_key_here' + req.headers['Content-Type'] = 'application/json' + req.body = { url: 'https://example.com/cat.jpg' }.to_json +end + +upload = JSON.parse(response.body) +puts "Uploaded to: #{upload['url']}" +``` + +--- + +## Error Handling + +When something goes wrong, you'll get an error response with details. + +**Status Codes:** + +- `200 OK` - Success! +- `201 Created` - File uploaded successfully +- `400 Bad Request` - Missing required parameters +- `401 Unauthorized` - Invalid or missing API key +- `404 Not Found` - Resource not found +- `422 Unprocessable Entity` - Validation failed + +**Error Response Format:** + +```json +{ + "error": "Missing file parameter" +} +``` + +Or with validation details: + +```json +{ + "error": "Validation failed", + "details": ["Name can't be blank"] +} +``` + +--- + +## Rate Limiting + +Be nice to our servers. While we don't enforce strict rate limits yet, please use the API responsibly. + +## Need Help? + +Got questions? Found a bug? Let us know! + +- Join the [#cdn channel on Slack](https://hackclub.enterprise.slack.com/archives/C016DEDUL87) +- Open an issue on [GitHub](https://github.com/hackclub/cdn/issues) + +Happy uploading! diff --git a/config/initializers/blind_index.rb b/config/initializers/blind_index.rb new file mode 100644 index 0000000..ff33367 --- /dev/null +++ b/config/initializers/blind_index.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +BlindIndex.master_key = ENV.fetch('BLIND_INDEX_MASTER_KEY') diff --git a/config/initializers/lockbox.rb b/config/initializers/lockbox.rb new file mode 100644 index 0000000..c526a5e --- /dev/null +++ b/config/initializers/lockbox.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +Lockbox.master_key = ENV.fetch('LOCKBOX_MASTER_KEY') diff --git a/config/routes.rb b/config/routes.rb index f973278..7c8de12 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,6 +3,7 @@ Rails.application.routes.draw do get "search", to: "search#index" resources :users, only: [:show, :destroy] resources :uploads, only: [:destroy] + resources :api_keys, only: [:destroy] end delete "/logout", to: "sessions#destroy", as: :logout @@ -14,6 +15,16 @@ Rails.application.routes.draw do resources :uploads, only: [:index, :new, :create, :destroy] + resources :api_keys, only: [:index, :create, :destroy] + + namespace :api do + namespace :v4 do + get "me", to: "users#show" + post "upload", to: "uploads#create" + post "upload_from_url", to: "uploads#create_from_url" + end + end + 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 diff --git a/test/controllers/api/v4/uploads_controller_test.rb b/test/controllers/api/v4/uploads_controller_test.rb new file mode 100644 index 0000000..003c080 --- /dev/null +++ b/test/controllers/api/v4/uploads_controller_test.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "test_helper" + +class API::V4::UploadsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:one) + @api_key = @user.api_keys.create!(name: "Test Key") + @token = @api_key.token + end + + test "should upload file with valid token" do + file = fixture_file_upload("test.png", "image/png") + + assert_difference("Upload.count", 1) do + post api_v4_upload_url, + params: { file: file }, + headers: { "Authorization" => "Bearer #{@token}" } + end + + assert_response :created + json = JSON.parse(response.body) + assert json["id"].present? + assert_equal "test.png", json["filename"] + assert json["url"].present? + assert json["created_at"].present? + end + + test "should reject upload without token" do + file = fixture_file_upload("test.png", "image/png") + + post api_v4_upload_url, params: { file: file } + + assert_response :unauthorized + end + + test "should reject upload without file parameter" do + post api_v4_upload_url, + headers: { "Authorization" => "Bearer #{@token}" } + + assert_response :bad_request + json = JSON.parse(response.body) + assert_equal "Missing file parameter", json["error"] + end + + test "should upload from URL with valid token" do + url = "https://example.com/test.jpg" + + # Stub the URI.open call + file_double = StringIO.new("fake image data") + URI.stub :open, file_double do + assert_difference("Upload.count", 1) do + post api_v4_upload_from_url_url, + params: { url: url }.to_json, + headers: { + "Authorization" => "Bearer #{@token}", + "Content-Type" => "application/json" + } + end + end + + assert_response :created + json = JSON.parse(response.body) + assert json["id"].present? + assert json["url"].present? + end + + test "should reject upload from URL without url parameter" do + post api_v4_upload_from_url_url, + params: {}.to_json, + headers: { + "Authorization" => "Bearer #{@token}", + "Content-Type" => "application/json" + } + + assert_response :bad_request + json = JSON.parse(response.body) + assert_equal "Missing url parameter", json["error"] + end + + test "should handle upload errors gracefully" do + url = "https://example.com/broken.jpg" + + # Simulate an error + URI.stub :open, -> (_) { raise StandardError, "Network error" } do + post api_v4_upload_from_url_url, + params: { url: url }.to_json, + headers: { + "Authorization" => "Bearer #{@token}", + "Content-Type" => "application/json" + } + end + + assert_response :unprocessable_entity + json = JSON.parse(response.body) + assert json["error"].include?("Upload failed") + end +end diff --git a/test/controllers/api/v4/users_controller_test.rb b/test/controllers/api/v4/users_controller_test.rb new file mode 100644 index 0000000..26eb12a --- /dev/null +++ b/test/controllers/api/v4/users_controller_test.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "test_helper" + +class API::V4::UsersControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:one) + @api_key = @user.api_keys.create!(name: "Test Key") + @token = @api_key.token + end + + test "should get user info with valid token" do + get api_v4_me_url, headers: { "Authorization" => "Bearer #{@token}" } + + assert_response :success + json = JSON.parse(response.body) + assert_equal @user.public_id, json["id"] + assert_equal @user.email, json["email"] + assert_equal @user.name, json["name"] + end + + test "should reject request without token" do + get api_v4_me_url + + assert_response :unauthorized + json = JSON.parse(response.body) + assert_equal "invalid_auth", json["error"] + end + + test "should reject request with invalid token" do + get api_v4_me_url, headers: { "Authorization" => "Bearer sk_cdn_invalid" } + + assert_response :unauthorized + json = JSON.parse(response.body) + assert_equal "invalid_auth", json["error"] + end + + test "should reject request with revoked token" do + @api_key.revoke! + + get api_v4_me_url, headers: { "Authorization" => "Bearer #{@token}" } + + assert_response :unauthorized + json = JSON.parse(response.body) + assert_equal "invalid_auth", json["error"] + end +end diff --git a/test/controllers/api_keys_controller_test.rb b/test/controllers/api_keys_controller_test.rb new file mode 100644 index 0000000..475a843 --- /dev/null +++ b/test/controllers/api_keys_controller_test.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "test_helper" + +class APIKeysControllerTest < ActionDispatch::IntegrationTest + include Devise::Test::IntegrationHelpers + + setup do + @user = users(:one) + sign_in @user + end + + test "should get index" do + get api_keys_url + assert_response :success + end + + test "should create api key" do + assert_difference("APIKey.count", 1) do + post api_keys_url, params: { api_key: { name: "New Key" } } + end + + assert_redirected_to api_keys_url + assert flash[:api_key_token].present? + assert flash[:api_key_token].start_with?("sk_cdn_") + end + + test "should not create api key without name" do + assert_no_difference("APIKey.count") do + post api_keys_url, params: { api_key: { name: "" } } + end + + assert_redirected_to api_keys_url + assert flash[:alert].present? + end + + test "should revoke api key" do + api_key = @user.api_keys.create!(name: "Test Key") + + delete api_key_url(api_key) + + assert_redirected_to api_keys_url + assert api_key.reload.revoked + end + + test "should not allow revoking other users api keys" do + other_user = users(:two) + api_key = other_user.api_keys.create!(name: "Other Key") + + delete api_key_url(api_key) + + assert_redirected_to api_keys_url + assert flash[:alert].present? + assert_not api_key.reload.revoked + end +end diff --git a/test/fixtures/api_keys.yml b/test/fixtures/api_keys.yml new file mode 100644 index 0000000..17672e9 --- /dev/null +++ b/test/fixtures/api_keys.yml @@ -0,0 +1,17 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + user: one + name: Test Key 1 + revoked: false + +two: + user: one + name: Test Key 2 + revoked: false + +revoked: + user: one + name: Revoked Key + revoked: true + revoked_at: <%= 1.day.ago.to_s(:db) %> diff --git a/test/fixtures/files/test.png b/test/fixtures/files/test.png new file mode 100644 index 0000000000000000000000000000000000000000..d624eebcc1a0c2b8e1a883dd19cb2b87282eba2c GIT binary patch literal 5182 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 Ua=0Y2oPCr*)NiYmsoo9zKkc_R;{X5v literal 0 HcmV?d00001 diff --git a/test/models/api_key_test.rb b/test/models/api_key_test.rb new file mode 100644 index 0000000..531d87b --- /dev/null +++ b/test/models/api_key_test.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "test_helper" + +class APIKeyTest < ActiveSupport::TestCase + test "generates token on create" do + user = users(:one) + api_key = user.api_keys.create!(name: "Test Key") + + assert api_key.token.present? + assert api_key.token.start_with?("sk_cdn_") + end + + test "token is encrypted in database" do + user = users(:one) + api_key = user.api_keys.create!(name: "Test Key") + + # Check that the ciphertext is different from the plaintext + raw_record = APIKey.connection.select_one( + "SELECT token_ciphertext FROM api_keys WHERE id = #{api_key.id}" + ) + assert_not_equal api_key.token, raw_record["token_ciphertext"] + end + + test "find_by_token uses blind index" do + user = users(:one) + api_key = user.api_keys.create!(name: "Test Key") + token = api_key.token + + found = APIKey.find_by_token(token) + assert_equal api_key.id, found.id + end + + test "find_by_token returns nil for invalid token" do + found = APIKey.find_by_token("sk_cdn_invalid_token") + assert_nil found + end + + test "active scope excludes revoked keys" do + active_count = APIKey.active.count + APIKey.create!(user: users(:one), name: "New Key") + + assert_equal active_count + 1, APIKey.active.count + end + + test "revoke! marks key as revoked" do + api_key = api_keys(:one) + assert api_key.active? + + api_key.revoke! + + assert api_key.revoked + assert_not api_key.active? + assert api_key.revoked_at.present? + end + + test "masked_token shows prefix and suffix" do + user = users(:one) + api_key = user.api_keys.create!(name: "Test Key") + + masked = api_key.masked_token + assert masked.include?("sk_cdn_") + assert masked.include?("....") + assert_equal 23, masked.length # "sk_cdn_" (7) + 6 chars + "...." (4) + 6 chars + end + + test "validates name presence" do + api_key = APIKey.new(user: users(:one)) + assert_not api_key.valid? + assert_includes api_key.errors[:name], "can't be blank" + end +end