mirror of
https://github.com/System-End/cdn.git
synced 2026-04-19 16:18:17 +00:00
api?
This commit is contained in:
parent
8b87856f05
commit
5846407c14
24 changed files with 1033 additions and 0 deletions
|
|
@ -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?
|
||||
|
|
|
|||
65
app/components/api_keys/_row.rb
Normal file
65
app/components/api_keys/_row.rb
Normal file
|
|
@ -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
|
||||
121
app/components/api_keys/index.rb
Normal file
121
app/components/api_keys/index.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
12
app/controllers/admin/api_keys_controller.rb
Normal file
12
app/controllers/admin/api_keys_controller.rb
Normal file
|
|
@ -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
|
||||
39
app/controllers/api/v4/application_controller.rb
Normal file
39
app/controllers/api/v4/application_controller.rb
Normal file
|
|
@ -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
|
||||
58
app/controllers/api/v4/uploads_controller.rb
Normal file
58
app/controllers/api/v4/uploads_controller.rb
Normal file
|
|
@ -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
|
||||
15
app/controllers/api/v4/users_controller.rb
Normal file
15
app/controllers/api/v4/users_controller.rb
Normal file
|
|
@ -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
|
||||
36
app/controllers/api_keys_controller.rb
Normal file
36
app/controllers/api_keys_controller.rb
Normal file
|
|
@ -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
|
||||
45
app/models/api_key.rb
Normal file
45
app/models/api_key.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'open-uri'
|
||||
|
||||
class Upload < ApplicationRecord
|
||||
include PgSearch::Model
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
16
app/policies/api_key_policy.rb
Normal file
16
app/policies/api_key_policy.rb
Normal file
|
|
@ -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
|
||||
4
app/views/api_keys/index.html.erb
Normal file
4
app/views/api_keys/index.html.erb
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<%= render Components::APIKeys::Index.new(
|
||||
api_keys: @api_keys,
|
||||
new_token: flash[:api_key_token]
|
||||
) %>
|
||||
277
app/views/docs/pages/api.md
Normal file
277
app/views/docs/pages/api.md
Normal file
|
|
@ -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!
|
||||
3
config/initializers/blind_index.rb
Normal file
3
config/initializers/blind_index.rb
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
BlindIndex.master_key = ENV.fetch('BLIND_INDEX_MASTER_KEY')
|
||||
3
config/initializers/lockbox.rb
Normal file
3
config/initializers/lockbox.rb
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Lockbox.master_key = ENV.fetch('LOCKBOX_MASTER_KEY')
|
||||
|
|
@ -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
|
||||
|
|
|
|||
98
test/controllers/api/v4/uploads_controller_test.rb
Normal file
98
test/controllers/api/v4/uploads_controller_test.rb
Normal file
|
|
@ -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
|
||||
47
test/controllers/api/v4/users_controller_test.rb
Normal file
47
test/controllers/api/v4/users_controller_test.rb
Normal file
|
|
@ -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
|
||||
56
test/controllers/api_keys_controller_test.rb
Normal file
56
test/controllers/api_keys_controller_test.rb
Normal file
|
|
@ -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
|
||||
17
test/fixtures/api_keys.yml
vendored
Normal file
17
test/fixtures/api_keys.yml
vendored
Normal file
|
|
@ -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) %>
|
||||
BIN
test/fixtures/files/test.png
vendored
Normal file
BIN
test/fixtures/files/test.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
72
test/models/api_key_test.rb
Normal file
72
test/models/api_key_test.rb
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue