This commit is contained in:
nora 2026-01-30 13:45:56 -05:00 committed by GitHub
parent 20ab70a936
commit ae63185445
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
193 changed files with 7573 additions and 1169 deletions

47
.dockerignore Normal file
View file

@ -0,0 +1,47 @@
# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files.
# Ignore git directory.
/.git/
/.gitignore
# Ignore bundler config.
/.bundle
# Ignore all environment files.
/.env*
# Ignore all default key files.
/config/master.key
/config/credentials/*.key
# Ignore all logfiles and tempfiles.
/log/*
/tmp/*
!/log/.keep
!/tmp/.keep
# Ignore pidfiles, but keep the directory.
/tmp/pids/*
!/tmp/pids/.keep
# Ignore storage (uploaded files in development and any SQLite databases).
/storage/*
!/storage/.keep
/tmp/storage/*
!/tmp/storage/.keep
# Ignore assets.
/node_modules/
/app/assets/builds/*
!/app/assets/builds/.keep
/public/assets
# Ignore CI service files.
/.github
# Ignore development files
/.devcontainer
# Ignore Docker-related files
/.dockerignore
/Dockerfile*

View file

@ -1,11 +1,58 @@
# S3 Config CF in this example
AWS_ACCESS_KEY_ID=1234567890abcdef
AWS_SECRET_ACCESS_KEY=abcdef1234567890
AWS_BUCKET_NAME=my-cdn-bucket
AWS_REGION=auto
AWS_ENDPOINT=https://<accountid>.r2.cloudflarestorage.com
AWS_CDN_URL=https://cdn.beans.com
# =============================================================================
# Cloudflare R2 Storage (S3-compatible)
# =============================================================================
R2_ACCESS_KEY_ID=your_access_key_id
R2_SECRET_ACCESS_KEY=your_secret_access_key
R2_BUCKET_NAME=your-bucket-name
R2_ENDPOINT=https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com
# API
API_TOKEN=beans # Set a secure random string
PORT=3000
# Public hostname for CDN URLs (used in generated links)
CDN_HOST=cdn.hackclub.com
# =============================================================================
# Hack Club OAuth
# =============================================================================
# Get credentials from Hack Club Auth (https://auth.hackclub.com)
HACKCLUB_CLIENT_ID=your_client_id
HACKCLUB_CLIENT_SECRET=your_client_secret
# Optional: Override auth URL (defaults to staging in dev, production in prod)
# HACKCLUB_AUTH_URL=https://auth.hackclub.com
# =============================================================================
# Encryption Keys
# =============================================================================
# Generate with: ruby -e "require 'securerandom'; puts SecureRandom.hex(32)"
LOCKBOX_MASTER_KEY=0000000000000000000000000000000000000000000000000000000000000000
BLIND_INDEX_MASTER_KEY=0000000000000000000000000000000000000000000000000000000000000000
# Active Record Encryption (generate with: bin/rails db:encryption:init)
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=your_primary_key
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=your_deterministic_key
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=your_key_derivation_salt
# =============================================================================
# Database (production only - dev uses cdn_development/cdn_test)
# =============================================================================
# DATABASE_HOST=localhost
# DATABASE_USER=cdn
# DATABASE_PASSWORD=your_password
# DATABASE_NAME=cdn_production
# Solid Cache/Queue/Cable databases (optional, defaults provided)
# CACHE_DATABASE_HOST=localhost
# QUEUE_DATABASE_HOST=localhost
# CABLE_DATABASE_HOST=localhost
# =============================================================================
# Optional
# =============================================================================
# Sentry error tracking
# SENTRY_DSN=https://your-sentry-dsn@sentry.io/project-id
# HashID salt (defaults to SECRET_KEY_BASE if not set)
# HASHID_SALT=your_hashid_salt
# Rails configuration
# PORT=3000
# RAILS_MAX_THREADS=5
# SECRET_KEY_BASE=your_secret_key_base

9
.gitattributes vendored Normal file
View file

@ -0,0 +1,9 @@
# See https://git-scm.com/docs/gitattributes for more about git attribute files.
# Mark the database schema as having been generated.
db/schema.rb linguist-generated
# Mark any vendored files as having been vendored.
vendor/* linguist-vendored
config/credentials/*.yml.enc diff=rails_credentials
config/credentials.yml.enc diff=rails_credentials

12
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,12 @@
version: 2
updates:
- package-ecosystem: bundler
directory: "/"
schedule:
interval: daily
open-pull-requests-limit: 10
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: daily
open-pull-requests-limit: 10

38
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,38 @@
name: CI
on:
pull_request:
push:
branches: [ main ]
jobs:
scan_ruby:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Scan for common Rails security vulnerabilities using static analysis
run: bin/brakeman --no-pager
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Lint code for consistent style
run: bin/rubocop -f github

50
.gitignore vendored
View file

@ -1,6 +1,44 @@
/node_modules/
/splitfornpm/
/.idea/
/.env
/package-lock.json
/.history
# See https://help.github.com/articles/ignoring-files for more about ignoring files.
#
# Temporary files generated by your text editor or operating system
# belong in git's global ignore instead:
# `$XDG_CONFIG_HOME/git/ignore` or `~/.config/git/ignore`
# Ignore bundler config.
/.bundle
# Ignore all environment files.
/.env*
# Ignore all logfiles and tempfiles.
/log/*
/tmp/*
!/log/.keep
!/tmp/.keep
# Ignore pidfiles, but keep the directory.
/tmp/pids/*
!/tmp/pids/
!/tmp/pids/.keep
# Ignore storage (uploaded files in development and any SQLite databases).
/storage/*
!/storage/.keep
/tmp/storage/*
!/tmp/storage/
!/tmp/storage/.keep
/public/assets
# Ignore master key for decrypting credentials and more.
/config/master.key
# Vite Ruby
/public/vite*
node_modules
# Vite uses dotenv and suggests to ignore local-only env files. See
# https://vitejs.dev/guide/env-and-mode.html#env-files
*.local
.idea

8
.rubocop.yml Normal file
View file

@ -0,0 +1,8 @@
# Omakase Ruby styling for Rails
inherit_gem: { rubocop-rails-omakase: rubocop.yml }
# Overwrite or add rules to create your own house style
#
# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`
# Layout/SpaceInsideArrayLiteralBrackets:
# Enabled: false

1
.ruby-version Normal file
View file

@ -0,0 +1 @@
ruby-3.4.4

View file

@ -1,23 +1,81 @@
# Use the official Bun image as base
FROM oven/bun:1
# syntax=docker/dockerfile:1
# check=error=true
# install curl for coolify healthcheck
RUN apt-get update && apt-get install -y curl wget
# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand:
# docker build -t cdn .
# docker run -d -p 80:80 -e RAILS_MASTER_KEY=<value from config/master.key> --name cdn cdn
# Set working directory
WORKDIR /app
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
# Copy package.json and bun.lockb (if exists)
COPY package*.json bun.lockb* ./
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=3.4.4
ARG NODE_VERSION=22
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
# Install dependencies
RUN bun install
# Rails app lives here
WORKDIR /rails
# Copy the rest of the application
# Install base packages
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Set production environment
ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development"
# Throw-away build stage to reduce size of final image
FROM base AS build
# Install packages needed to build gems and Node.js/Yarn
ARG NODE_VERSION
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config curl && \
curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \
apt-get install --no-install-recommends -y nodejs && \
corepack enable && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Install application gems
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
bundle exec bootsnap precompile --gemfile
# Install Node.js dependencies
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
# Copy application code
COPY . .
# Expose the port your Express server runs on
EXPOSE 3000
# Precompile bootsnap code for faster boot times
RUN bundle exec bootsnap precompile app/ lib/
# Start the server
CMD ["bun", "run", "start"]
# Build Vite assets and precompile Rails assets
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
# Final stage for app image
FROM base
# Copy built artifacts: gems, application
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails
# Run and own only the runtime files as a non-root user for security
RUN groupadd --system --gid 1000 rails && \
useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
chown -R rails:rails db log storage tmp
USER 1000:1000
# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]
# Start server via Thruster by default, this can be overwritten at runtime
EXPOSE 80
CMD ["./bin/thrust", "./bin/rails", "server"]

72
Gemfile Normal file
View file

@ -0,0 +1,72 @@
source "https://rubygems.org"
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.0.4"
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
gem "propshaft"
# Use postgres as the database for Active Record
gem "pg", "~> 1.3"
# Use the Puma web server [https://github.com/puma/puma]
gem "puma", ">= 5.0"
# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
# gem "bcrypt", "~> 3.1.7"
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ windows jruby ]
# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable
gem "solid_cache"
gem "solid_queue"
gem "solid_cable"
# Reduces boot times through caching; required in config/boot.rb
gem "bootsnap", require: false
# Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/]
gem "thruster", require: false
# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
# gem "image_processing", "~> 1.2"
group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
# Static analysis for security vulnerabilities [https://brakemanscanner.org/]
gem "brakeman", require: false
# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
gem "rubocop-rails-omakase", require: false
end
group :development do
# Use console on exceptions pages [https://github.com/rails/web-console]
gem "web-console"
end
group :test do
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
gem "capybara"
gem "selenium-webdriver"
end
gem "jb"
gem "pry-rails", group: :development
gem "awesome_print"
gem "dotenv-rails", groups: [ :development, :test ]
gem "hashid-rails"
gem "vite_rails"
gem "phlex-rails"
gem "omniauth"
gem "omniauth-hack_club"
gem "faraday"
gem "pundit"
gem "primer_view_components"
gem "pg_search"
gem "kaminari"
gem "high_voltage"
gem "redcarpet"
gem "lockbox"
gem "blind_index"
gem "sentry-ruby"
gem "sentry-rails"

497
Gemfile.lock Normal file
View file

@ -0,0 +1,497 @@
GEM
remote: https://rubygems.org/
specs:
actioncable (8.0.4)
actionpack (= 8.0.4)
activesupport (= 8.0.4)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.0.4)
actionpack (= 8.0.4)
activejob (= 8.0.4)
activerecord (= 8.0.4)
activestorage (= 8.0.4)
activesupport (= 8.0.4)
mail (>= 2.8.0)
actionmailer (8.0.4)
actionpack (= 8.0.4)
actionview (= 8.0.4)
activejob (= 8.0.4)
activesupport (= 8.0.4)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.0.4)
actionview (= 8.0.4)
activesupport (= 8.0.4)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.0.4)
actionpack (= 8.0.4)
activerecord (= 8.0.4)
activestorage (= 8.0.4)
activesupport (= 8.0.4)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.0.4)
activesupport (= 8.0.4)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.0.4)
activesupport (= 8.0.4)
globalid (>= 0.3.6)
activemodel (8.0.4)
activesupport (= 8.0.4)
activerecord (8.0.4)
activemodel (= 8.0.4)
activesupport (= 8.0.4)
timeout (>= 0.4.0)
activestorage (8.0.4)
actionpack (= 8.0.4)
activejob (= 8.0.4)
activerecord (= 8.0.4)
activesupport (= 8.0.4)
marcel (~> 1.0)
activesupport (8.0.4)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
argon2-kdf (0.3.1)
fiddle
ast (2.4.3)
awesome_print (1.9.2)
base64 (0.3.0)
benchmark (0.5.0)
bigdecimal (4.0.1)
bindex (0.8.1)
blind_index (2.7.0)
activesupport (>= 7.1)
argon2-kdf (>= 0.2)
bootsnap (1.21.1)
msgpack (~> 1.2)
brakeman (8.0.1)
racc
builder (3.3.0)
capybara (3.40.0)
addressable
matrix
mini_mime (>= 0.1.3)
nokogiri (~> 1.11)
rack (>= 1.6.0)
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
coderay (1.1.3)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
crass (1.0.6)
date (3.5.1)
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
dotenv (3.2.0)
dotenv-rails (3.2.0)
dotenv (= 3.2.0)
railties (>= 6.1)
drb (2.2.3)
dry-cli (1.4.1)
erb (6.0.1)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
faraday (2.14.0)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-net_http (3.4.2)
net-http (~> 0.5)
fiddle (1.1.8)
fugit (1.12.1)
et-orbi (~> 1.4)
raabro (~> 1.4)
globalid (1.3.0)
activesupport (>= 6.1)
hashid-rails (1.4.1)
activerecord (>= 4.0)
hashids (~> 1.0)
hashids (1.0.6)
hashie (5.1.0)
logger
high_voltage (5.0.0)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
io-console (0.8.2)
irb (1.16.0)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jb (0.8.2)
json (2.18.0)
jwt (3.1.2)
base64
kaminari (1.2.2)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2)
kaminari-activerecord (= 1.2.2)
kaminari-core (= 1.2.2)
kaminari-actionview (1.2.2)
actionview
kaminari-core (= 1.2.2)
kaminari-activerecord (1.2.2)
activerecord
kaminari-core (= 1.2.2)
kaminari-core (1.2.2)
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
lockbox (2.1.0)
logger (1.7.0)
loofah (2.25.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.0)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.1.0)
matrix (0.4.3)
method_source (1.1.0)
mini_mime (1.1.5)
minitest (6.0.1)
prism (~> 1.5)
msgpack (1.8.0)
multi_xml (0.8.1)
bigdecimal (>= 3.1, < 5)
mutex_m (0.3.0)
net-http (0.9.1)
uri (>= 0.11.1)
net-imap (0.6.2)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.2)
timeout
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
nokogiri (1.19.0-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.0-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.19.0-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.0-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.19.0-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.0-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.0-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.0-x86_64-linux-musl)
racc (~> 1.4)
oauth2 (2.0.18)
faraday (>= 0.17.3, < 4.0)
jwt (>= 1.0, < 4.0)
logger (~> 1.2)
multi_xml (~> 0.5)
rack (>= 1.2, < 4)
snaky_hash (~> 2.0, >= 2.0.3)
version_gem (~> 1.1, >= 1.1.9)
octicons (19.21.2)
omniauth (2.1.4)
hashie (>= 3.4.6)
logger
rack (>= 2.2.3)
rack-protection
omniauth-hack_club (1.0.1)
omniauth-oauth2 (~> 1.8)
omniauth-oauth2 (1.9.0)
oauth2 (>= 2.0.2, < 3)
omniauth (~> 2.0)
parallel (1.27.0)
parser (3.3.10.1)
ast (~> 2.4.1)
racc
pg (1.6.3)
pg (1.6.3-aarch64-linux)
pg (1.6.3-aarch64-linux-musl)
pg (1.6.3-arm64-darwin)
pg (1.6.3-x86_64-darwin)
pg (1.6.3-x86_64-linux)
pg (1.6.3-x86_64-linux-musl)
pg_search (2.3.7)
activerecord (>= 6.1)
activesupport (>= 6.1)
phlex (2.4.0)
refract (~> 1.0)
zeitwerk (~> 2.7)
phlex-rails (2.4.0)
phlex (~> 2.4.0)
railties (>= 7.1, < 9)
zeitwerk (~> 2.7)
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
primer_view_components (0.49.0)
actionview (>= 7.2.0)
activesupport (>= 7.2.0)
octicons (>= 18.0.0)
view_component (>= 3.1, < 5.0)
prism (1.8.0)
propshaft (1.3.1)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
pry (0.16.0)
coderay (~> 1.1)
method_source (~> 1.0)
reline (>= 0.6.0)
pry-rails (0.3.11)
pry (>= 0.13.0)
psych (5.3.1)
date
stringio
public_suffix (7.0.2)
puma (7.2.0)
nio4r (~> 2.0)
pundit (2.5.2)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.4)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack-proxy (0.7.7)
rack
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
rails (8.0.4)
actioncable (= 8.0.4)
actionmailbox (= 8.0.4)
actionmailer (= 8.0.4)
actionpack (= 8.0.4)
actiontext (= 8.0.4)
actionview (= 8.0.4)
activejob (= 8.0.4)
activemodel (= 8.0.4)
activerecord (= 8.0.4)
activestorage (= 8.0.4)
activesupport (= 8.0.4)
bundler (>= 1.15.0)
railties (= 8.0.4)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.0.4)
actionpack (= 8.0.4)
activesupport (= 8.0.4)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.1)
rdoc (7.1.0)
erb
psych (>= 4.0.0)
tsort
redcarpet (3.6.1)
refract (1.1.0)
prism
zeitwerk
regexp_parser (2.11.3)
reline (0.6.3)
io-console (~> 0.5)
rexml (3.4.4)
rubocop (1.84.0)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.49.0)
parser (>= 3.3.7.2)
prism (~> 1.7)
rubocop-performance (1.26.1)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.34.3)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rails-omakase (1.1.0)
rubocop (>= 1.72)
rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0)
rubyzip (3.2.2)
securerandom (0.4.1)
selenium-webdriver (4.40.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
sentry-rails (6.3.0)
railties (>= 5.2.0)
sentry-ruby (~> 6.3.0)
sentry-ruby (6.3.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
snaky_hash (2.0.3)
hashie (>= 0.1.0, < 6)
version_gem (>= 1.1.8, < 3)
solid_cable (3.0.12)
actioncable (>= 7.2)
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
solid_cache (1.0.10)
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
solid_queue (1.3.1)
activejob (>= 7.1)
activerecord (>= 7.1)
concurrent-ruby (>= 1.3.1)
fugit (~> 1.11)
railties (>= 7.1)
thor (>= 1.3.1)
stringio (3.2.0)
thor (1.5.0)
thruster (0.1.17)
thruster (0.1.17-aarch64-linux)
thruster (0.1.17-arm64-darwin)
thruster (0.1.17-x86_64-darwin)
thruster (0.1.17-x86_64-linux)
timeout (0.6.0)
tsort (0.2.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
uri (1.1.1)
useragent (0.16.11)
version_gem (1.1.9)
view_component (4.2.0)
actionview (>= 7.1.0)
activesupport (>= 7.1.0)
concurrent-ruby (~> 1)
vite_rails (3.0.20)
railties (>= 5.1, < 9)
vite_ruby (~> 3.0, >= 3.2.2)
vite_ruby (3.9.2)
dry-cli (>= 0.7, < 2)
logger (~> 1.6)
mutex_m
rack-proxy (~> 0.6, >= 0.6.1)
zeitwerk (~> 2.2)
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
websocket (1.2.11)
websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.7.4)
PLATFORMS
aarch64-linux
aarch64-linux-gnu
aarch64-linux-musl
arm-linux-gnu
arm-linux-musl
arm64-darwin
x86_64-darwin
x86_64-linux
x86_64-linux-gnu
x86_64-linux-musl
DEPENDENCIES
awesome_print
blind_index
bootsnap
brakeman
capybara
debug
dotenv-rails
faraday
hashid-rails
high_voltage
jb
kaminari
lockbox
omniauth
omniauth-hack_club
pg (~> 1.3)
pg_search
phlex-rails
primer_view_components
propshaft
pry-rails
puma (>= 5.0)
pundit
rails (~> 8.0.4)
redcarpet
rubocop-rails-omakase
selenium-webdriver
sentry-rails
sentry-ruby
solid_cable
solid_cache
solid_queue
thruster
tzinfo-data
vite_rails
web-console
BUNDLED WITH
2.7.2

3
Procfile.dev Normal file
View file

@ -0,0 +1,3 @@
vite: bin/vite dev
web: bin/rails s

188
README.md
View file

@ -14,122 +14,106 @@
</a>
</div>
---
## 📡 API Usage
A Rails 8 application for hosting and managing CDN uploads, with OAuth authentication via Hack Club.
- All API endpoints require authentication via `Authorization: Bearer api-token` header
- Use the API_TOKEN from your environment configuration
- Failure to include a valid token will result in 401 Unauthorized responses
## Prerequisites
### V3 API (Latest)
<img alt="Version 3" src="https://files.catbox.moe/e3ravk.png" align="right" width="300">
- Ruby 3.4.4 (see `.ruby-version`)
- PostgreSQL
- Node.js + Yarn (for Vite frontend)
- A Cloudflare R2 bucket (or S3-compatible storage)
**Endpoint:** `POST https://cdn.hackclub.com/api/v3/new`
## Setup
**Headers:**
```
Authorization: Bearer api-token
Content-Type: application/json
```
1. **Clone and install dependencies:**
```bash
git clone https://github.com/hackclub/cdn.git
cd cdn
bundle install
yarn install
```
**Request Example:**
2. **Configure environment variables:**
```bash
cp .env.example .env
```
Edit `.env` with your credentials (see below for details).
3. **Setup the database:**
```bash
bin/rails db:create db:migrate
```
4. **Generate encryption keys** (for API key encryption):
```bash
# Generate a 32-byte hex key for Lockbox
ruby -e "require 'securerandom'; puts SecureRandom.hex(32)"
# Generate a 32-byte hex key for BlindIndex
ruby -e "require 'securerandom'; puts SecureRandom.hex(32)"
# Generate Active Record encryption keys
bin/rails db:encryption:init
```
5. **Start the development servers:**
```bash
# In one terminal, run the Vite dev server:
bin/vite dev
# In another terminal, run the Rails server:
bin/rails server
```
## Environment Variables
See `.env.example` for the full list. Key variables:
| Variable | Description |
|----------|-------------|
| `R2_ACCESS_KEY_ID` | Cloudflare R2 access key |
| `R2_SECRET_ACCESS_KEY` | Cloudflare R2 secret key |
| `R2_BUCKET_NAME` | R2 bucket name |
| `R2_ENDPOINT` | R2 endpoint URL |
| `CDN_HOST` | Public hostname for CDN URLs |
| `HACKCLUB_CLIENT_ID` | OAuth client ID from Hack Club Auth |
| `HACKCLUB_CLIENT_SECRET` | OAuth client secret |
| `LOCKBOX_MASTER_KEY` | 64-char hex key for encrypting API keys |
| `BLIND_INDEX_MASTER_KEY` | 64-char hex key for searchable encryption |
## API
The API uses bearer token authentication. Create an API key from the web dashboard after logging in.
**Upload a file:**
```bash
curl --location 'https://cdn.hackclub.com/api/v3/new' \
--header 'Authorization: Bearer beans' \
--header 'Content-Type: application/json' \
--data '[
"https://assets.hackclub.com/flag-standalone.svg",
"https://assets.hackclub.com/flag-orpheus-left.png"
]'
curl -X POST https://cdn.hackclub.com/api/v4/upload \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "file=@image.png"
```
**Response:**
```json
{
"files": [
{
"deployedUrl": "https://hc-cdn.hel1.your-objectstorage.com/s/v3/64a9472006c4472d7ac75f2d4d9455025d9838d6_flag-standalone.svg",
"file": "0_64a9472006c4472d7ac75f2d4d9455025d9838d6_flag-standalone.svg",
"sha": "64a9472006c4472d7ac75f2d4d9455025d9838d6",
"size": 4365
},
{
"deployedUrl": "https://hc-cdn.hel1.your-objectstorage.com/s/v3/d926bfd9811ebfe9172187793a171a5cbcc61992_flag-orpheus-left.png",
"file": "1_d926bfd9811ebfe9172187793a171a5cbcc61992_flag-orpheus-left.png",
"sha": "d926bfd9811ebfe9172187793a171a5cbcc61992",
"size": 8126
}
],
"cdnBase": "https://hc-cdn.hel1.your-objectstorage.com"
}
**Upload from URL:**
```bash
curl -X POST https://cdn.hackclub.com/api/v4/upload_from_url \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/image.png"}'
```
<details>
<summary>V2 API</summary>
See `/docs` in the running app for full API documentation.
<img alt="Version 2" src="https://files.catbox.moe/uuk1vm.png" align="right" width="300">
## Architecture
**Endpoint:** `POST https://cdn.hackclub.com/api/v2/new`
**Headers:**
```
Authorization: Bearer api-token
Content-Type: application/json
```
**Request Example:**
```json
[
"https://assets.hackclub.com/flag-standalone.svg",
"https://assets.hackclub.com/flag-orpheus-left.png"
]
```
**Response:**
```json
{
"flag-standalone.svg": "https://cdn.example.dev/s/v2/flag-standalone.svg",
"flag-orpheus-left.png": "https://cdn.example.dev/s/v2/flag-orpheus-left.png"
}
```
</details>
<details>
<summary>V1 API</summary>
<img alt="Version 1" src="https://files.catbox.moe/tnzdfe.png" align="right" width="300">
**Endpoint:** `POST https://cdn.hackclub.com/api/v1/new`
**Headers:**
```
Authorization: Bearer api-token
Content-Type: application/json
```
**Request Example:**
```json
[
"https://assets.hackclub.com/flag-standalone.svg",
"https://assets.hackclub.com/flag-orpheus-left.png"
]
```
**Response:**
```json
[
"https://cdn.example.dev/s/v1/0_flag-standalone.svg",
"https://cdn.example.dev/s/v1/1_flag-orpheus-left.png"
]
```
</details>
# Technical Details
- **Storage Structure:** `/s/v3/{HASH}_{filename}`
- **File Naming:** `/s/{slackUserId}/{unix}_{sanitizedFilename}`
- **Rails 8** with **Vite** for frontend assets
- **Phlex** + **Primer ViewComponents** for UI
- **Active Storage** with Cloudflare R2 backend
- **Solid Queue/Cache/Cable** for background jobs and caching (production)
- **Pundit** for authorization
- **Lockbox + BlindIndex** for API key encryption
<div align="center">
<br>
<p>Made with 💜 for Hack Club</p>
</div>
</div>

6
Rakefile Normal file
View file

@ -0,0 +1,6 @@
# Add your own tasks in files placed in lib/tasks ending in .rake,
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
require_relative "config/application"
Rails.application.load_tasks

View file

@ -0,0 +1,144 @@
# frozen_string_literal: true
class Components::Admin::Search::Index < Components::Base
include Phlex::Rails::Helpers::FormWith
include Phlex::Rails::Helpers::LinkTo
def initialize(query: nil, users: [], uploads: [], type: "all")
@query = query
@users = users
@uploads = uploads
@type = type
end
def view_template
div(style: "max-width: 1200px; margin: 0 auto; padding: 24px;") do
header_section
tabs_section
search_form
results_section if @query.present?
end
end
private
def header_section
header(style: "margin-bottom: 24px;") do
h1(style: "font-size: 2rem; font-weight: 600; margin: 0;") { "Admin Search" }
p(style: "color: var(--fgColor-muted, #656d76); margin: 8px 0 0; font-size: 14px;") do
"Search users and uploads by ID, email, filename, URL, etc."
end
end
end
def tabs_section
render Primer::Alpha::UnderlineNav.new(label: "Search type") do |nav|
nav.with_tab(selected: @type == "all", href: admin_search_path(type: "all", q: @query)) { "All" }
nav.with_tab(selected: @type == "users", href: admin_search_path(type: "users", q: @query)) { "Users" }
nav.with_tab(selected: @type == "uploads", href: admin_search_path(type: "uploads", q: @query)) { "Uploads" }
end
end
def search_form
div(style: "margin-bottom: 24px; margin-top: 16px;") do
form_with url: admin_search_path, method: :get, style: "display: flex; gap: 8px;" do
input(type: "hidden", name: "type", value: @type)
input(
type: "search",
name: "q",
placeholder: search_placeholder,
value: @query,
class: "form-control",
style: "flex: 1; max-width: 600px;",
autofocus: true
)
button(type: "submit", class: "btn btn-primary") do
render Primer::Beta::Octicon.new(icon: :search, mr: 1)
plain "Search"
end
end
end
end
def search_placeholder
case @type
when "users" then "Search by ID, email, name, slack_id..."
when "uploads" then "Search by ID, filename, URL, uploader..."
else "Search by ID, email, filename, URL..."
end
end
def results_section
if @users.empty? && @uploads.empty?
empty_state
else
users_section if @users.any?
uploads_section if @uploads.any?
end
end
def users_section
div(style: "margin-bottom: 32px;") do
h2(style: "font-size: 1.25rem; font-weight: 600; margin-bottom: 12px;") do
plain "Users "
render(Primer::Beta::Label.new(scheme: :secondary)) { plain @users.size.to_s }
end
render Primer::Beta::BorderBox.new do |box|
@users.each do |user|
box.with_row do
user_row(user)
end
end
end
end
end
def user_row(user)
div(style: "display: flex; justify-content: space-between; align-items: center;") do
div do
div(style: "font-weight: 500;") { user.name || "Unnamed" }
div(style: "font-size: 12px; color: var(--fgColor-muted);") do
plain user.email
plain " · "
code(style: "font-size: 11px;") { user.public_id }
end
end
div(style: "display: flex; align-items: center; gap: 16px;") do
div(style: "text-align: right; font-size: 12px; color: var(--fgColor-muted);") do
div { "#{user.total_files} files" }
div { user.total_storage_formatted }
end
if user.is_admin?
render(Primer::Beta::Label.new(scheme: :accent)) { plain "ADMIN" }
end
link_to admin_user_path(user), class: "btn btn-sm", title: "View user" do
render Primer::Beta::Octicon.new(icon: :eye, size: :small)
end
end
end
end
def uploads_section
div do
h2(style: "font-size: 1.25rem; font-weight: 600; margin-bottom: 12px;") do
plain "Uploads "
render(Primer::Beta::Label.new(scheme: :secondary)) { plain @uploads.size.to_s }
end
render Primer::Beta::BorderBox.new do |box|
@uploads.each do |upload|
box.with_row do
render Components::Uploads::Row.new(upload: upload, compact: true, admin: true)
end
end
end
end
end
def empty_state
render Primer::Beta::Blankslate.new(border: true) do |component|
component.with_visual_icon(icon: :search)
component.with_heading(tag: :h2) { "No results found" }
component.with_description { "Try a different search query" }
end
end
end

View file

@ -0,0 +1,197 @@
# frozen_string_literal: true
class Components::Admin::Users::Show < Components::Base
include Phlex::Rails::Helpers::LinkTo
include Phlex::Rails::Helpers::ButtonTo
def initialize(user:)
@user = user
end
def view_template
div(style: "max-width: 800px; margin: 0 auto; padding: 24px;") do
header_section
stats_section
quota_section
api_keys_section
uploads_section
end
end
private
def header_section
header(style: "margin-bottom: 24px;") do
div(style: "display: flex; justify-content: space-between; align-items: flex-start;") do
div do
div(style: "display: flex; align-items: center; gap: 8px; margin-bottom: 8px;") do
h1(style: "font-size: 2rem; font-weight: 600; margin: 0;") { @user.name || "Unnamed User" }
if @user.is_admin?
render(Primer::Beta::Label.new(scheme: :accent)) { plain "ADMIN" }
end
end
p(style: "color: var(--fgColor-muted, #656d76); margin: 0; font-size: 14px;") do
plain @user.email
plain " · "
code(style: "font-size: 12px;") { @user.public_id }
end
if @user.slack_id.present?
p(style: "color: var(--fgColor-muted); margin: 4px 0 0; font-size: 12px;") do
plain "Slack: "
code { @user.slack_id }
end
end
end
link_to admin_search_path, class: "btn" do
render Primer::Beta::Octicon.new(icon: :"arrow-left", mr: 1)
plain "Back to Search"
end
end
end
end
def stats_section
div(style: "display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 24px;") do
stat_card("Total Files", @user.total_files.to_s)
stat_card("Total Storage", @user.total_storage_formatted)
stat_card("Member Since", @user.created_at.strftime("%b %d, %Y"))
end
end
def stat_card(label, value)
render Primer::Beta::BorderBox.new do |box|
box.with_body(padding: :normal) do
div(style: "font-size: 12px; color: var(--fgColor-muted);") { label }
div(style: "font-size: 24px; font-weight: 600; margin-top: 4px;") { value }
end
end
end
def quota_section
quota_service = QuotaService.new(@user)
usage = quota_service.current_usage
policy = quota_service.current_policy
div(style: "margin-bottom: 24px;") do
h2(style: "font-size: 1.25rem; font-weight: 600; margin-bottom: 12px;") { "Quota Management" }
render Primer::Beta::BorderBox.new do |box|
box.with_body(padding: :normal) do
# Current policy
div(style: "margin-bottom: 16px;") do
div(style: "display: flex; align-items: center; gap: 8px; margin-bottom: 8px;") do
span(style: "font-weight: 500;") { "Current Policy:" }
render(Primer::Beta::Label.new(scheme: quota_policy_scheme)) { policy.slug.to_s.humanize }
if @user.quota_policy.present?
render(Primer::Beta::Label.new(scheme: :accent)) { "Override" }
end
end
div(style: "font-size: 12px; color: var(--fgColor-muted);") do
plain "Per-file limit: #{helpers.number_to_human_size(policy.max_file_size)} · "
plain "Total storage: #{helpers.number_to_human_size(policy.max_total_storage)}"
end
end
# Usage stats
div(style: "margin-bottom: 16px;") do
div(style: "font-weight: 500; margin-bottom: 4px;") { "Storage Usage" }
div(style: "font-size: 14px; margin-bottom: 4px;") do
plain "#{helpers.number_to_human_size(usage[:storage_used])} / #{helpers.number_to_human_size(usage[:storage_limit])} "
span(style: "color: var(--fgColor-muted);") { "(#{usage[:percentage_used]}%)" }
end
# Progress bar
div(style: "background: var(--bgColor-muted); border-radius: 3px; height: 8px; overflow: hidden;") do
div(style: "background: #{progress_bar_color(usage[:percentage_used])}; height: 100%; width: #{[ usage[:percentage_used], 100 ].min}%;")
end
end
# Admin controls
form(action: helpers.set_quota_admin_user_path(@user), method: :post, style: "display: flex; gap: 8px; align-items: center;") do
input(type: "hidden", name: "_method", value: "patch")
input(type: "hidden", name: "authenticity_token", value: helpers.form_authenticity_token)
render(Primer::Alpha::Select.new(name: "quota_policy", size: :small)) do |select|
select.with_option(label: "Auto-detect (via HCA)", value: "", selected: @user.quota_policy.nil?)
select.with_option(label: "Verified", value: "verified", selected: @user.quota_policy == "verified")
select.with_option(label: "Functionally Unlimited", value: "functionally_unlimited", selected: @user.quota_policy == "functionally_unlimited")
end
button(type: "submit", class: "btn btn-sm btn-primary") { "Set Policy" }
end
end
end
end
end
def quota_policy_scheme
case @user.quota_policy&.to_sym
when :functionally_unlimited
:success
when :verified
:accent
else
:default
end
end
def progress_bar_color(percentage)
if percentage >= 100
"var(--bgColor-danger-emphasis)"
elsif percentage >= 80
"var(--bgColor-attention-emphasis)"
else
"var(--bgColor-success-emphasis)"
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" }
render Primer::Beta::BorderBox.new do |box|
api_keys.each do |api_key|
box.with_row do
api_key_row(api_key)
end
end
end
end
end
def api_key_row(api_key)
div(style: "display: flex; justify-content: space-between; align-items: center;") 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?
render(Primer::Beta::Label.new(scheme: :danger)) { plain "REVOKED" }
else
render(Primer::Beta::Label.new(scheme: :success)) { plain "ACTIVE" }
button_to helpers.admin_api_key_path(api_key), method: :delete, class: "btn btn-sm btn-danger", data: { confirm: "Revoke this API key?" } do
plain "Revoke"
end
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?
div do
h2(style: "font-size: 1.25rem; font-weight: 600; margin-bottom: 12px;") { "Recent Uploads" }
render Primer::Beta::BorderBox.new do |box|
uploads.each do |upload|
box.with_row do
render Components::Uploads::Row.new(upload: upload, compact: true, admin: true)
end
end
end
end
end
end

View file

@ -0,0 +1,55 @@
# frozen_string_literal: true
class Components::APIKeys::Row < Components::Base
include Phlex::Rails::Helpers::FormWith
def initialize(api_key:)
@api_key = api_key
end
def view_template
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, color: :muted)
span(style: "font-size: 14px; font-weight: 500;") { api_key.name }
end
code(style: "font-size: 12px; color: var(--fgColor-muted, #656d76);") { api_key.masked_token }
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
private
attr_reader :api_key
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

View file

@ -0,0 +1,101 @@
# 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
render Primer::Beta::Flash.new(scheme: :success, mb: 4) do |component|
component.with_icon(icon: :check)
div do
p(style: "margin: 0 0 8px; font-weight: 600;") { "API key created successfully!" }
p(style: "margin: 0 0 8px;") { "Copy your API key now. You won't be able to see it again!" }
code(style: "display: block; padding: 12px; background: var(--bgColor-default, #fff); border: 1px solid var(--borderColor-default); border-radius: 6px; font-size: 14px; word-break: break-all;") do
plain new_token
end
end
end
end
def create_form
render Primer::Beta::BorderBox.new(mb: 4) do |box|
box.with_header do
h2(style: "font-size: 14px; font-weight: 600; margin: 0;") { "Create new API key" }
end
box.with_body do
form_with url: api_keys_path, method: :post do
div(style: "margin-bottom: 12px; max-width: 400px;") do
label(for: "api_key_name", style: "display: block; font-size: 14px; font-weight: 600; margin-bottom: 8px;") do
plain "Key name"
end
input(
type: "text",
name: "api_key[name]",
id: "api_key_name",
placeholder: "e.g., My App",
required: true,
class: "form-control"
)
end
button(type: "submit", class: "btn btn-primary") do
render Primer::Beta::Octicon.new(icon: :key, mr: 1)
plain "Create 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?
render Primer::Beta::BorderBox.new do |box|
api_keys.each do |api_key|
box.with_row do
render Components::APIKeys::Row.new(api_key: api_key)
end
end
end
else
empty_state
end
end
end
def empty_state
render Primer::Beta::Blankslate.new(border: true) do |component|
component.with_visual_icon(icon: :key)
component.with_heading(tag: :h3) { "No API keys yet" }
component.with_description { "Create your first API key to get started with the API" }
end
end
end

17
app/components/base.rb Normal file
View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
class Components::Base < Phlex::HTML
register_value_helper :admin_tool
register_value_helper :current_user
# Include any helpers you want to be available across all components
include Phlex::Rails::Helpers::Routes
include Phlex::Rails::Helpers::ButtonTo
include Phlex::Rails::Helpers::TimeAgoInWords
if Rails.env.development?
def before_template
comment { "Before #{self.class.name}" }
super
end
end
end

View file

@ -0,0 +1,32 @@
# frozen_string_literal: true
class Components::Docs::Content < Components::Base
def initialize(doc:)
@doc = doc
end
def view_template
style do
raw(<<~CSS.html_safe)
.markdown-body h1 { font-size: 2em; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid var(--borderColor-default); }
.markdown-body h2 { font-size: 1.5em; margin-top: 24px; margin-bottom: 16px; }
.markdown-body h3 { font-size: 1.25em; margin-top: 24px; margin-bottom: 16px; }
.markdown-body p { margin-bottom: 16px; line-height: 1.6; }
.markdown-body ul, .markdown-body ol { padding-left: 2em; margin-bottom: 16px; }
.markdown-body li { margin-bottom: 4px; }
.markdown-body code { background: var(--bgColor-muted); padding: 2px 6px; border-radius: 4px; font-size: 85%; }
.markdown-body pre { background: var(--bgColor-muted); padding: 16px; border-radius: 6px; overflow-x: auto; margin-bottom: 16px; }
.markdown-body pre code { background: none; padding: 0; }
.markdown-body a { color: var(--fgColor-accent); }
.markdown-body blockquote { padding: 0 1em; color: var(--fgColor-muted); border-left: 4px solid var(--borderColor-default); margin-bottom: 16px; }
.markdown-body table { border-collapse: collapse; margin-bottom: 16px; width: 100%; }
.markdown-body th, .markdown-body td { border: 1px solid var(--borderColor-default); padding: 8px 12px; }
.markdown-body th { background: var(--bgColor-muted); }
CSS
end
article(class: "markdown-body") do
raw @doc.content.html_safe
end
end
end

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
class Components::Docs::Page < Components::Base
def initialize(doc:, docs:)
@doc = doc
@docs = docs
end
def view_template
div(class: "d-flex", style: "min-height: calc(100vh - 64px);") do
render Components::Docs::Sidebar.new(docs: @docs, current_doc: @doc)
main(class: "flex-auto p-4 p-md-5", style: "max-width: 900px;") do
render Components::Docs::Content.new(doc: @doc)
end
end
end
end

View file

@ -0,0 +1,36 @@
# frozen_string_literal: true
class Components::Docs::Sidebar < Components::Base
def initialize(docs:, current_doc:)
@docs = docs
@current_doc = current_doc
end
def view_template
aside(
class: "color-bg-subtle border-right",
style: "width: 280px; min-width: 280px; padding: 24px 16px;"
) do
div(class: "mb-3") do
a(href: root_path, class: "color-fg-muted text-small d-flex flex-items-center") do
render Primer::Beta::Octicon.new(icon: "arrow-left", size: :small, mr: 1)
plain "Back to CDN"
end
end
h2(class: "h5 mb-3") { "Documentation" }
render Primer::Beta::NavList.new(aria: { label: "Documentation" }) do |nav|
@docs.each do |doc|
nav.with_item(
label: doc.title,
href: doc_path(doc.id),
selected: doc.id == @current_doc.id
) do |item|
item.with_leading_visual_icon(icon: doc.icon)
end
end
end
end
end
end

View file

@ -0,0 +1,41 @@
# frozen_string_literal: true
class Components::HeaderBar < Components::Base
register_value_helper :signed_in?
register_value_helper :impersonating?
def view_template
header(class: "app-header", style: "display: flex; align-items: center; justify-content: space-between;") do
div(style: "display: flex; align-items: center; gap: 1rem;") do
a(href: root_path, class: "app-header-brand", style: "text-decoration: none; color: inherit;") do
plain "Hack Club CDN"
sup(class: "app-header-env-badge") { "(dev)" } if Rails.env.development?
end
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
a(href: admin_search_path, style: "color: var(--fgColor-default); text-decoration: none; font-size: 14px;") { "Search" }
end
end
end
return unless signed_in?
div(style: "display: flex; align-items: center; gap: 0.5rem;") do
render(Primer::Alpha::ActionMenu.new(anchor_align: :end)) do |menu|
menu.with_show_button(scheme: :invisible) do |btn|
btn.with_leading_visual_icon(icon: impersonating? ? :eye : :person)
plain current_user.name
end
menu.with_item(label: "Log out", href: logout_path, form_arguments: { method: :delete }) do |item|
item.with_leading_visual_icon(icon: :"sign-out")
end
end
end
end
end
end

View file

@ -0,0 +1,28 @@
# frozen_string_literal: true
class Components::Inspector < Components::Base
def initialize(object:)
@object = object
end
def view_template
admin_tool do
details class: "inspector" do
summary { record_id }
pre class: "inspector-content" do
unless @object.nil?
raw safe(ap @object)
else
plain "nil"
end
end
end
end
end
private
def record_id
"#{@object.class.name} #{@object&.try(:public_id) || @object&.id}"
end
end

View file

@ -0,0 +1,51 @@
# frozen_string_literal: true
class Components::StaticPages::Base < Components::Base
def stat_card(title, value, icon)
div(style: "padding: 14px; background: var(--bgColor-default, #fff); border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px;") do
div(style: "display: flex; justify-content: space-between; align-items: flex-start;") do
div do
p(style: "font-size: 11px; color: var(--fgColor-muted, #656d76); margin: 0 0 4px; text-transform: uppercase; letter-spacing: 0.3px;") { title }
span(style: "font-size: 28px; font-weight: 600; line-height: 1;") { value.to_s }
end
span(style: "color: var(--fgColor-muted, #656d76);") do
render Primer::Beta::Octicon.new(icon: icon, size: :small)
end
end
end
end
def link_panel(title, links)
div(style: "background: var(--bgColor-default, #fff); border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px; overflow: hidden;") do
div(style: "padding: 12px 16px; border-bottom: 1px solid var(--borderColor-default, #d0d7de); background: var(--bgColor-muted, #f6f8fa);") do
h3(style: "font-size: 14px; font-weight: 600; margin: 0;") { title }
end
div(style: "padding: 8px 0;") do
links.each do |link|
a(
href: link[:href],
target: link[:href].start_with?("http") ? "_blank" : nil,
rel: link[:href].start_with?("http") ? "noopener" : nil,
style: "display: flex; align-items: center; gap: 12px; padding: 10px 16px; text-decoration: none; color: inherit;"
) do
span(style: "color: var(--fgColor-muted, #656d76);") do
render Primer::Beta::Octicon.new(icon: link[:icon], size: :small)
end
span(style: "font-size: 14px;") { link[:label] }
end
end
end
end
end
def resources_panel
links = [
{ label: "Documentation", href: doc_path("getting-started"), icon: :book },
{ label: "GitHub Repo", href: "https://github.com/hackclub/cdn", icon: :"mark-github" },
{ label: "Use via Slack", href: "https://hackclub.enterprise.slack.com/archives/C016DEDUL87", icon: :"comment-discussion" },
{ label: "Help with development?", href: "https://hackclub.enterprise.slack.com/archives/C0ACGUA6XTJ", icon: :heart },
{ label: "Report an Issue", href: "https://github.com/hackclub/cdn/issues", icon: :"issue-opened" }
]
link_panel("Resources", links)
end
end

View file

@ -0,0 +1,128 @@
# frozen_string_literal: true
class Components::StaticPages::Home < Components::StaticPages::Base
def initialize(stats:, user:, flavor_text:)
@stats = stats
@user = user
@flavor_text = flavor_text
end
def view_template
div(style: "max-width: 1200px; margin: 0 auto; padding: 24px;") do
header_section
kpi_section
main_section
end
end
private
attr_reader :stats, :user, :flavor_text
def header_section
header(style: "display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 16px; padding-bottom: 24px; margin-bottom: 24px; border-bottom: 1px solid var(--borderColor-default, #d0d7de);") do
div do
p(style: "color: var(--fgColor-muted, #656d76); margin: 0 0 4px; font-size: 14px;") do
plain "Welcome back, "
strong { user&.name || "friend" }
end
h1(style: "font-size: 2rem; font-weight: 300; margin: 0;") { "Hack Club CDN" }
div(style: "margin-top: 8px;") do
render(Primer::Beta::Label.new(scheme: :secondary)) { flavor_text }
end
end
div(style: "display: flex; gap: 8px; flex-wrap: wrap;") do
a(href: helpers.docs_path("getting-started"), class: "btn") do
render Primer::Beta::Octicon.new(icon: :book, mr: 1)
plain "Docs"
end
a(href: helpers.uploads_path, class: "btn btn-primary") do
render Primer::Beta::Octicon.new(icon: :upload, mr: 1)
plain "Upload"
end
end
end
end
def kpi_section
div(style: "margin-bottom: 32px;") do
# Your stats section
h2(style: "font-size: 14px; font-weight: 600; color: var(--fgColor-muted, #656d76); text-transform: uppercase; letter-spacing: 0.5px; margin: 0 0 12px;") { "Your Stats" }
div(style: "display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 24px;") do
stat_card("Total files", stats[:total_files], :archive)
stat_card("Storage used", stats[:storage_formatted], :database)
stat_card("Uploaded today", stats[:files_today], :upload)
stat_card("This week", stats[:files_this_week], :zap)
quota_stat_card
end
# Recent uploads
if stats[:recent_uploads].any?
h2(style: "font-size: 14px; font-weight: 600; color: var(--fgColor-muted, #656d76); text-transform: uppercase; letter-spacing: 0.5px; margin: 24px 0 12px;") { "Recent Uploads" }
recent_uploads_list
end
end
end
def recent_uploads_list
render Primer::Beta::BorderBox.new do |box|
stats[:recent_uploads].each do |upload|
box.with_row do
render Components::Uploads::Row.new(upload: upload, compact: true)
end
end
end
end
def quota_stat_card
quota_data = stats[:quota]
available = quota_data[:available]
limit = quota_data[:storage_limit]
percentage = quota_data[:percentage_used]
# Color based on usage
color = if percentage >= 100
"var(--fgColor-danger)"
elsif percentage >= 80
"var(--fgColor-attention)"
else
"var(--fgColor-success)"
end
progress_color = if percentage >= 100
"var(--bgColor-danger-emphasis)"
elsif percentage >= 80
"var(--bgColor-attention-emphasis)"
else
"var(--bgColor-success-emphasis)"
end
div(style: "padding: 14px; background: var(--bgColor-default, #fff); border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px;") do
div(style: "display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 8px;") do
div do
p(style: "font-size: 11px; color: var(--fgColor-muted, #656d76); margin: 0 0 4px; text-transform: uppercase; letter-spacing: 0.3px;") { "Available storage" }
span(style: "font-size: 28px; font-weight: 600; line-height: 1; color: #{color};") do
helpers.number_to_human_size(available)
end
p(style: "font-size: 11px; color: var(--fgColor-muted); margin: 4px 0 0;") do
plain "of #{helpers.number_to_human_size(limit)}"
end
end
span(style: "color: var(--fgColor-muted, #656d76);") do
render Primer::Beta::Octicon.new(icon: :"shield-check", size: :small)
end
end
# Progress bar
div(style: "background: var(--bgColor-muted); border-radius: 3px; height: 6px; overflow: hidden;") do
div(style: "background: #{progress_color}; height: 100%; width: #{[ percentage, 100 ].min}%;")
end
end
end
def main_section
div(style: "display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 24px;") do
resources_panel
end
end
end

View file

@ -0,0 +1,76 @@
# frozen_string_literal: true
class Components::StaticPages::LoggedOut < Components::StaticPages::Base
def initialize(stats:, flavor_text:)
@stats = stats
@flavor_text = flavor_text
end
def view_template
div(style: "max-width: 1200px; margin: 0 auto; padding: 48px 24px 24px;") do
header_section
stats_section
main_section
end
end
private
attr_reader :stats, :flavor_text
def header_section
header(style: "display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 16px; padding-bottom: 24px; margin-bottom: 24px; border-bottom: 1px solid var(--borderColor-default, #d0d7de);") do
div do
h1(style: "font-size: 2rem; font-weight: 300; margin: 0 0 8px;") do
plain "Hack Club CDN"
sup(style: "font-size: 0.5em; margin-left: 4px;") { "v4" }
end
p(style: "color: var(--fgColor-muted, #656d76); margin: 0 0 8px; max-width: 600px;") do
plain "File hosting for Hack Clubbers."
end
render(Primer::Beta::Label.new(scheme: :secondary)) { flavor_text }
end
div(style: "display: flex; gap: 8px; flex-wrap: wrap;") do
button_to "Sign in with Hack Club", "/auth/hack_club", method: :post, class: "btn btn-primary", data: { turbo: false }
end
end
end
def stats_section
div(style: "margin-bottom: 32px;") do
h2(style: "font-size: 14px; font-weight: 600; color: var(--fgColor-muted, #656d76); text-transform: uppercase; letter-spacing: 0.5px; margin: 0 0 12px;") { "State of the Platform:" }
div(style: "display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 24px;") do
stat_card("Total files", stats[:total_files], :archive)
stat_card("Storage used", stats[:storage_formatted], :database)
stat_card("Users", stats[:total_users], :people)
stat_card("Files this week", stats[:files_this_week], :zap)
end
h2(style: "font-size: 14px; font-weight: 600; color: var(--fgColor-muted, #656d76); text-transform: uppercase; letter-spacing: 0.5px; margin: 24px 0 12px;") { "New in V4:" }
div(style: "display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px;") do
feature_card(:lock, "Invincible", "Backups of the underlying storage exist.")
feature_card(:link, "No broken links, this time?", "it lives on a domain! that we own!")
feature_card(:"shield-check", "Hopefully reliable", 'Backed by the award-winning "cc @nora" service guarantee.')
end
end
end
def feature_card(icon, title, description)
div(style: "padding: 14px; background: var(--bgColor-default, #fff); border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px;") do
div(style: "display: flex; align-items: center; gap: 10px; margin-bottom: 6px;") do
span(style: "color: var(--fgColor-muted, #656d76);") do
render Primer::Beta::Octicon.new(icon: icon, size: :small)
end
h3(style: "font-size: 14px; font-weight: 600; margin: 0;") { title }
end
p(style: "font-size: 12px; color: var(--fgColor-muted, #656d76); margin: 0;") { description }
end
end
def main_section
div(style: "display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 24px;") do
resources_panel
end
end
end

View file

@ -0,0 +1,118 @@
# frozen_string_literal: true
class Components::Uploads::Row < Components::Base
include Phlex::Rails::Helpers::FormWith
include Phlex::Rails::Helpers::LinkTo
def initialize(upload:, compact: false, admin: false)
@upload = upload
@compact = compact
@admin = admin
end
def view_template
div(style: "display: flex; justify-content: space-between; align-items: #{compact ? 'center' : 'flex-start'}; gap: 16px;") do
if compact
compact_content
else
full_content
end
end
end
private
attr_reader :upload, :compact, :admin
def compact_content
div(style: "flex: 1; min-width: 0;") do
div(style: "font-size: 14px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;") do
render Primer::Beta::Octicon.new(icon: file_icon_for(upload.content_type), size: :small, mr: 1)
plain upload.filename.to_s
end
div(style: "font-size: 12px; color: var(--fgColor-muted, #656d76); margin-top: 4px;") do
plain "#{upload.human_file_size}#{time_ago_in_words(upload.created_at)} ago"
end
end
div(style: "display: flex; gap: 8px; align-items: center;") do
render(Primer::Beta::ClipboardCopyButton.new(value: upload.cdn_url, size: :small, "aria-label": "Copy link")) { "Copy link" }
a(href: upload.cdn_url, target: "_blank", rel: "noopener", class: "btn btn-sm") do
plain "View"
end
render_delete_dialog
end
end
def full_content
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: file_icon_for(upload.content_type), size: :small)
div(style: "font-size: 14px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;") do
plain upload.filename.to_s
end
render(Primer::Beta::Label.new(scheme: :secondary)) { plain upload.provenance.titleize }
end
div(style: "font-size: 12px; color: var(--fgColor-muted, #656d76);") do
plain "#{upload.human_file_size}#{upload.content_type}#{time_ago_in_words(upload.created_at)} ago"
end
end
div(style: "display: flex; gap: 8px; align-items: center;") do
render(Primer::Beta::ClipboardCopyButton.new(value: upload.cdn_url, size: :small, "aria-label": "Copy link")) { "Copy link" }
a(href: upload.cdn_url, target: "_blank", rel: "noopener", class: "btn btn-sm") do
render Primer::Beta::Octicon.new(icon: :link, mr: 1)
plain "View"
end
render_delete_dialog
end
end
def render_delete_dialog
render Primer::Alpha::Dialog.new(title: "Delete file?", 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;") { "Delete #{upload.filename}?" }
end
dialog.with_body do
p(style: "margin: 0;") do
plain "This action cannot be undone. The file will be permanently removed from the CDN."
end
end
dialog.with_footer do
div(style: "display: flex; justify-content: flex-end; gap: 8px;") do
form_with url: (admin ? admin_upload_path(upload) : upload_path(upload)), method: :delete, style: "display: inline;" do
button(type: "submit", class: "btn btn-danger") do
plain "Delete"
end
end
end
end
end
end
def file_icon_for(content_type)
case content_type
when /image/
:image
when /video/
:video
when /audio/
:unmute
when /pdf/
:file
when /zip|rar|tar|gz/
:"file-zip"
when /text|json|xml/
:code
else
:file
end
end
end

View file

@ -0,0 +1,107 @@
# frozen_string_literal: true
class Components::Uploads::Index < Components::Base
include Phlex::Rails::Helpers::FormWith
include Phlex::Rails::Helpers::LinkTo
register_output_helper :paginate
def initialize(uploads:, query: nil)
@uploads = uploads
@query = query
end
def view_template
dropzone_form
div(style: "max-width: 1200px; margin: 0 auto; padding: 24px;") do
header_section
search_section
uploads_list
pagination_section if uploads.respond_to?(:total_pages) && uploads.total_pages > 1
end
end
private
attr_reader :uploads, :query
def header_section
header(style: "display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;") do
div do
h1(style: "font-size: 2rem; font-weight: 600; margin: 0;") { "Your Uploads" }
p(style: "color: var(--fgColor-muted, #656d76); margin: 8px 0 0; font-size: 14px;") do
count = uploads.respond_to?(:total_count) ? uploads.total_count : uploads.size
plain "#{count} file#{count == 1 ? '' : 's'}"
end
end
label(for: "dropzone-file-input", class: "btn btn-primary", style: "cursor: pointer;") do
render Primer::Beta::Octicon.new(icon: :upload, mr: 1)
plain "Upload File"
end
end
end
def search_section
div(style: "margin-bottom: 24px;") do
form_with url: uploads_path, method: :get do
div(style: "display: flex; gap: 12px;") do
input(
type: "search",
name: "query",
placeholder: "Search files...",
value: query,
class: "form-control",
style: "flex: 1; max-width: 400px;"
)
button(type: "submit", class: "btn") do
render Primer::Beta::Octicon.new(icon: :search, mr: 1)
plain "Search"
end
end
end
end
end
def uploads_list
if uploads.any?
render Primer::Beta::BorderBox.new do |box|
uploads.each do |upload|
box.with_row do
render Components::Uploads::Row.new(upload: upload, compact: false)
end
end
end
else
empty_state
end
end
def empty_state
render Primer::Beta::Blankslate.new(border: true) do |component|
component.with_visual_icon(icon: query.present? ? :search : :upload, size: :medium)
component.with_heading(tag: :h2) do
query.present? ? "No files found" : "Drop files here"
end
component.with_description do
if query.present?
"Try a different search query"
else
"Drag and drop files anywhere on this page, or use the Upload button"
end
end
end
end
def pagination_section
div(style: "margin-top: 24px; text-align: center;") do
paginate uploads
end
end
def dropzone_form
form_with url: uploads_path, method: :post, multipart: true, data: { dropzone_form: true } do
input(type: "file", name: "file", id: "dropzone-file-input", data: { dropzone_input: true }, style: "display: none;")
end
end
end

View 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

View file

@ -0,0 +1,14 @@
module Admin
class ApplicationController < ::ApplicationController
before_action :require_admin!
private
def require_admin!
redirect_to(
root_path,
alert: "You need to be an admin to access this page."
) unless current_user&.is_admin?
end
end
end

View file

@ -0,0 +1,24 @@
# frozen_string_literal: true
module Admin
class SearchController < ApplicationController
def index
@query = params[:q].to_s.strip
@type = params[:type] || "all"
return if @query.blank?
@users = search_users(@query) if @type.in?(%w[all users])
@uploads = search_uploads(@query) if @type.in?(%w[all uploads])
end
private
def search_users(query)
User.search(query).limit(20)
end
def search_uploads(query)
Upload.search(query).includes(:blob, :user).order(created_at: :desc).limit(50)
end
end
end

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
module Admin
class UploadsController < ApplicationController
before_action :set_upload
def destroy
filename = @upload.filename
@upload.destroy!
redirect_to admin_search_path, notice: "Upload #{filename} deleted."
end
private
def set_upload
@upload = Upload.find(params[:id])
end
end
end

View file

@ -0,0 +1,40 @@
# frozen_string_literal: true
module Admin
class UsersController < ApplicationController
before_action :set_user
def show
end
def destroy
@user.destroy!
redirect_to admin_search_path, notice: "User #{@user.name || @user.email} deleted."
end
def set_quota
quota_policy = params[:quota_policy]
# Empty string means auto-detect (clear override)
if quota_policy.blank?
@user.update!(quota_policy: nil)
redirect_to admin_user_path(@user), notice: "Quota policy cleared. Will auto-detect via HCA."
return
end
unless %w[verified functionally_unlimited].include?(quota_policy)
redirect_to admin_user_path(@user), alert: "Invalid quota policy."
return
end
@user.update!(quota_policy: quota_policy)
redirect_to admin_user_path(@user), notice: "Quota policy set to #{quota_policy.humanize}."
end
private
def set_user
@user = User.find_by_public_id!(params[:id])
end
end
end

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
module API
module V4
class APIKeysController < ApplicationController
def revoke
api_key = current_token
owner_email = current_user.email
key_name = api_key.name
api_key.revoke!
render json: {
success: true,
owner_email: owner_email,
key_name: key_name,
status: "complete"
}, status: :ok
end
end
end
end

View file

@ -0,0 +1,53 @@
module API
module V4
class ApplicationController < ActionController::API
include ActionController::HttpAuthentication::Token::ControllerMethods
attr_reader :current_user, :current_token
before_action :authenticate!
before_action :set_sentry_context
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
rescue_from StandardError, with: :handle_error
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 set_sentry_context
Sentry.set_user(id: current_user&.id) if current_user
Sentry.set_tags(api_key_id: current_token&.hashid) if current_token
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
def handle_error(exception)
raise exception if Rails.env.local?
event_id = Sentry.capture_exception(exception)
render json: { error: exception.message, error_id: event_id }, status: :internal_server_error
end
end
end
end

View file

@ -0,0 +1,108 @@
# frozen_string_literal: true
module API
module V4
class UploadsController < ApplicationController
before_action :check_quota, only: [ :create, :create_from_url ]
# 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
download_auth = request.headers["X-Download-Authorization"]
upload = Upload.create_from_url(url, user: current_user, provenance: :api, original_url: url, authorization: download_auth)
# Check quota after download (URL upload size unknown beforehand)
quota_service = QuotaService.new(current_user)
unless quota_service.can_upload?(0) # Already uploaded, check if now over quota
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
return
end
end
render json: upload_json(upload), status: :created
rescue => e
render json: { error: "Upload failed: #{e.message}" }, status: :unprocessable_entity
end
private
def check_quota
# For direct uploads, check file size before processing
if params[:file].present?
file_size = params[:file].size
quota_service = QuotaService.new(current_user)
policy = quota_service.current_policy
# 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
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
nil
end
end
# For URL uploads, quota is checked after download in create_from_url
end
def quota_error_json(usage, custom_message = nil)
{
error: custom_message || "Storage quota exceeded",
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)
{
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

View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
module API
module V4
class UsersController < ApplicationController
def show
quota_service = QuotaService.new(current_user)
usage = quota_service.current_usage
render json: {
id: current_user.public_id,
email: current_user.email,
name: current_user.name,
storage_used: usage[:storage_used],
storage_limit: usage[:storage_limit],
quota_tier: usage[:policy]
}
end
end
end
end

View 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

View file

@ -0,0 +1,55 @@
class ApplicationController < ActionController::Base
before_action :require_authentication!
before_action :set_sentry_context
helper_method :current_user, :signed_in?, :impersonating?
rescue_from StandardError, with: :handle_error
private
def current_user
@current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
end
def signed_in? = current_user.present?
def require_authentication!
redirect_to root_path, alert: "Please sign in to continue." unless signed_in?
end
def impersonating? = false
include Pundit::Authorization
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
private
def set_sentry_context
Sentry.set_user(id: current_user&.id, email: current_user&.email) if signed_in?
end
def user_not_authorized
flash[:alert] = "You are not authorized to perform this action."
redirect_back fallback_location: root_path
end
def handle_error(exception)
raise exception if Rails.env.local?
event_id = Sentry.capture_exception(exception)
respond_to do |format|
format.html do
if request.path == root_path
render "errors/internal_server_error", status: :internal_server_error, locals: { error_id: event_id, error_message: exception.message }
else
flash[:alert] = "Something went wrong: #{exception.message} (Error ID: #{event_id})"
redirect_back fallback_location: root_path
end
end
format.json { render json: { error: exception.message, error_id: event_id }, status: :internal_server_error }
end
end
end

View file

View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
class DocsController < ApplicationController
include HighVoltage::StaticPage
skip_before_action :require_authentication!
before_action :load_docs_navigation
def show
@doc = DocPage.find(params[:id])
end
private
def load_docs_navigation
@docs = DocPage.all
end
def page_finder_factory
DocPage
end
end

View file

@ -0,0 +1,62 @@
# frozen_string_literal: true
class ExternalUploadsController < ApplicationController
skip_before_action :require_authentication!
def show
upload = Upload.includes(:blob).find(params[:id])
redirect_to rails_blob_url(upload.blob), allow_other_host: true
rescue ActiveRecord::RecordNotFound
head :not_found
end
def rescue
url = params[:url]
if url.blank?
head :bad_request
return
end
upload = Upload.includes(:blob).find_by(original_url: url)
if upload
redirect_to upload.cdn_url, allow_other_host: true
else
render_not_found_response(url)
end
end
private
def render_not_found_response(url)
if url.match?(/\.(png|jpe?g)$/i)
render_error_image
else
head :not_found
end
end
def render_error_image
svg = <<~SVG
<svg width="800" height="400" xmlns="http://www.w3.org/2000/svg">
<rect width="800" height="400" fill="#FBECED"/>
<circle cx="400" cy="140" r="40" fill="#EC3750" opacity="0.2"/>
<text x="400" y="150" font-family="Phantom Sans, system-ui, -apple-system, sans-serif" font-size="32" fill="#EC3750" text-anchor="middle" font-weight="700">
404
</text>
<text x="400" y="210" font-family="Phantom Sans, system-ui, -apple-system, sans-serif" font-size="20" fill="#1F2D3D" text-anchor="middle" font-weight="600">
Original URL not found in CDN
</text>
<text x="400" y="250" font-family="Phantom Sans, system-ui, -apple-system, sans-serif" font-size="14" fill="#3C4858" text-anchor="middle">
This file hasn't been uploaded or rescued yet.
</text>
<text x="400" y="280" font-family="Phantom Sans, system-ui, -apple-system, sans-serif" font-size="14" fill="#3C4858" text-anchor="middle">
Try uploading it at cdn.hackclub.com
</text>
</svg>
SVG
render inline: svg, content_type: "image/svg+xml"
end
end

View file

@ -0,0 +1,25 @@
# frozen_string_literal: true
class SessionsController < ApplicationController
skip_before_action :require_authentication!, only: %i[create failure]
def create
auth = request.env["omniauth.auth"]
user = User.find_or_create_from_omniauth(auth)
session[:user_id] = user.id
# Check and upgrade verification status if needed
QuotaService.new(user).check_and_upgrade_verification!
redirect_to root_path, notice: "Signed in successfully!"
end
def destroy
reset_session
redirect_to root_path, notice: "Signed out successfully!"
end
def failure
redirect_to root_path, alert: "Authentication failed: #{params[:message]}"
end
end

View file

@ -0,0 +1,12 @@
class StaticPagesController < ApplicationController
skip_before_action :require_authentication!, only: [ :home ]
def home
@flavor_text = FlavorTextService.new(user: current_user).generate
if signed_in?
@user_stats = CDNStatsService.user_stats(current_user)
else
@global_stats = CDNStatsService.global_stats
end
end
end

View file

@ -0,0 +1,77 @@
# frozen_string_literal: true
class UploadsController < ApplicationController
before_action :set_upload, only: [ :destroy ]
before_action :check_quota, only: [ :create ]
def index
@uploads = current_user.uploads.includes(:blob).recent
if params[:query].present?
@uploads = @uploads.search_by_filename(params[:query])
end
@uploads = @uploads.page(params[:page]).per(50)
end
def create
uploaded_file = params[:file]
if uploaded_file.blank?
redirect_to uploads_path, alert: "Please select a file to upload."
return
end
blob = ActiveStorage::Blob.create_and_upload!(
io: uploaded_file.tempfile,
filename: uploaded_file.original_filename,
content_type: uploaded_file.content_type
)
@upload = current_user.uploads.create!(
blob: blob,
provenance: :web
)
redirect_to uploads_path, notice: "File uploaded successfully!"
rescue StandardError => e
redirect_to uploads_path, alert: "Upload failed: #{e.message}"
end
def destroy
authorize @upload
@upload.destroy!
redirect_back fallback_location: uploads_path, notice: "Upload deleted successfully."
rescue Pundit::NotAuthorizedError
redirect_back fallback_location: uploads_path, alert: "You are not authorized to delete this upload."
end
private
def check_quota
uploaded_file = params[:file]
return if uploaded_file.blank? # Let create action handle missing file
quota_service = QuotaService.new(current_user)
file_size = uploaded_file.size
policy = quota_service.current_policy
# Check per-file size limit
if file_size > policy.max_file_size
redirect_to uploads_path, alert: "File size (#{ActiveSupport::NumberHelper.number_to_human_size(file_size)}) exceeds your limit of #{ActiveSupport::NumberHelper.number_to_human_size(policy.max_file_size)} per file."
return
end
# Check if upload would exceed total storage quota
unless quota_service.can_upload?(file_size)
usage = quota_service.current_usage
redirect_to uploads_path, alert: "Uploading this file would exceed your storage quota. You're using #{ActiveSupport::NumberHelper.number_to_human_size(usage[:storage_used])} of #{ActiveSupport::NumberHelper.number_to_human_size(usage[:storage_limit])}."
nil
end
end
def set_upload
@upload = Upload.find(params[:id])
end
end

View file

@ -0,0 +1,112 @@
(function() {
let dropzone;
let counter = 0;
let fileInput, form;
let initialized = false;
function init() {
const formElement = document.querySelector("[data-dropzone-form]");
if (!formElement) {
fileInput = null;
form = null;
initialized = false;
return;
}
if (initialized && form === formElement) return;
form = formElement;
fileInput = form.querySelector("[data-dropzone-input]");
if (!fileInput) return;
initialized = true;
// Handle file input change
fileInput.addEventListener("change", (e) => {
const file = e.target.files[0];
if (file) {
form.requestSubmit();
}
});
}
// Prevent default drag behaviors
document.addEventListener("dragover", (e) => {
e.preventDefault();
});
// Show overlay when dragging enters window
document.addEventListener("dragenter", (e) => {
if (!fileInput) return;
e.preventDefault();
if (counter === 0) {
showDropzone();
}
counter++;
});
// Hide overlay when dragging leaves window
document.addEventListener("dragleave", (e) => {
if (!fileInput) return;
e.preventDefault();
counter--;
if (counter === 0) {
hideDropzone();
}
});
// Handle file drop
document.addEventListener("drop", (e) => {
if (!fileInput) return;
e.preventDefault();
counter = 0;
hideDropzone();
const files = e.dataTransfer.files;
if (files.length > 0) {
fileInput.files = files;
form.requestSubmit();
}
});
// Show full-screen dropzone overlay
function showDropzone() {
if (!dropzone) {
dropzone = document.createElement("div");
dropzone.classList.add("file-dropzone");
const title = document.createElement("h1");
title.innerText = "Drop your file here";
dropzone.appendChild(title);
document.body.appendChild(dropzone);
document.body.style.overflow = "hidden";
// Force reflow for transition
void dropzone.offsetWidth;
dropzone.classList.add("visible");
}
}
// Hide full-screen dropzone overlay
function hideDropzone() {
if (dropzone) {
dropzone.remove();
dropzone = null;
document.body.style.overflow = "auto";
counter = 0;
}
}
// Initialize on first load
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
// Re-initialize on Turbo navigations
document.addEventListener("turbo:load", init);
})();

View file

@ -0,0 +1,5 @@
import Rails from "@rails/ujs";
Rails.start();
import "@primer/view-components/app/components/primer/primer.js";
import "../controllers/upload_dropzone.js";

View file

@ -0,0 +1,22 @@
@use '@primer/css/dist/primer.css';
@use '@primer/view-components/app/assets/styles/primer_view_components.css';
@use '@primer/primitives/dist/css/primitives.css';
@use '@primer/primitives/dist/css/functional/themes/light.css';
@use '@primer/primitives/dist/css/functional/themes/dark.css';
@use "@/styles/dark_mode";
@use "@/styles/hca";
@use "@/styles/admin_tool";
@use "@/styles/file_dropzone";
.app-header {
position: static;
top: 0;
z-index: 100;
display: flex;
align-items: center;
gap: var(--base-size-12, 12px);
padding: var(--base-size-12, 12px) var(--base-size-16, 16px);
background-color: var(--bgColor-default);
border-bottom: 1px solid var(--borderColor-muted);
}

View file

@ -0,0 +1,9 @@
.admin-tool {
padding: 0.25rem 0.5rem;
border-radius: 0.5rem;
border: 1px dashed #ff8c37;
background: rgba(#ff8c37, 0.125);
overflow: auto;
display: inline-flex;
align-items: center;
}

View file

View file

@ -0,0 +1,99 @@
.file-dropzone {
background-color: rgba(255, 255, 255, 0.95);
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
// Animated dashed border using gradient technique
background-image:
linear-gradient(90deg, #0969da 50%, transparent 50%),
linear-gradient(90deg, #0969da 50%, transparent 50%),
linear-gradient(0deg, #0969da 50%, transparent 50%),
linear-gradient(0deg, #0969da 50%, transparent 50%);
background-repeat: repeat-x, repeat-x, repeat-y, repeat-y;
background-size:
50px 8px,
50px 8px,
8px 50px,
8px 50px;
background-position:
0 0,
0 100%,
0 100%,
100% 20px;
animation: border-dance 1s infinite linear;
opacity: 0;
transition-property: opacity;
transition-duration: 300ms;
transition-timing-function: ease-out;
padding: 3rem;
text-align: center;
h1 {
padding-bottom: 0;
border: none;
color: #0969da;
font-size: 2.5rem;
font-weight: 600;
transition-property: transform;
transition-duration: 300ms;
transition-timing-function: ease-out;
transform: scale(1.08);
}
}
.file-dropzone.visible {
opacity: 1;
h1 {
transform: none;
}
}
@keyframes border-dance {
0% {
background-position:
0 0,
0 100%,
0 100%,
100% 20px;
}
100% {
background-position:
-50px 0,
50px 100%,
0 calc(100% + 50px),
100% -30px;
}
}
// Dark mode support
@media (prefers-color-scheme: dark) {
.file-dropzone {
background-color: rgba(13, 17, 23, 0.95);
h1 {
color: #58a6ff;
}
background-image:
linear-gradient(90deg, #58a6ff 50%, transparent 50%),
linear-gradient(90deg, #58a6ff 50%, transparent 50%),
linear-gradient(0deg, #58a6ff 50%, transparent 50%),
linear-gradient(0deg, #58a6ff 50%, transparent 50%);
}
}

View file

View file

@ -0,0 +1,6 @@
module ApplicationHelper
def admin_tool(class_name: "", element: "div", **options, &block)
return unless current_user&.is_admin?
concat content_tag(element, class: "admin-tool #{class_name}", **options, &block)
end
end

View file

@ -0,0 +1,29 @@
# frozen_string_literal: true
module QuotaHelper
def quota_banner_for(user)
quota_service = QuotaService.new(user)
usage = quota_service.current_usage
if quota_service.over_quota?
# Danger banner when over quota
render Primer::Beta::Flash.new(scheme: :danger, full: true) do
plain "You've exceeded your storage quota. "
plain "You're using #{number_to_human_size(usage[:storage_used])} of #{number_to_human_size(usage[:storage_limit])}. "
plain "Please delete some files to continue uploading."
end
elsif quota_service.at_warning?
# Warning banner when >= 80% used
render Primer::Beta::Flash.new(scheme: :warning, full: true) do
plain "You're using #{usage[:percentage_used]}% of your storage quota "
plain "(#{number_to_human_size(usage[:storage_used])} of #{number_to_human_size(usage[:storage_limit])}). "
if usage[:policy] == "unverified"
plain "Get verified at "
a(href: "https://auth.hackclub.com", target: "_blank", rel: "noopener") { "auth.hackclub.com" }
plain " to unlock 50GB of storage."
end
end
end
# Return nil if no warning needed
end
end

View file

@ -0,0 +1,2 @@
module StaticPagesHelper
end

View file

@ -0,0 +1,7 @@
class ApplicationJob < ActiveJob::Base
# Automatically retry jobs that encountered a deadlock
# retry_on ActiveRecord::Deadlocked
# Most jobs are safe to ignore if the underlying records are no longer available
# discard_on ActiveJob::DeserializationError
end

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
class RefreshCDNStatsJob < ApplicationJob
queue_as :default
def perform
CDNStatsService.refresh_global_stats!
end
end

View file

@ -0,0 +1,4 @@
class ApplicationMailer < ActionMailer::Base
default from: "from@example.com"
layout "mailer"
end

45
app/models/api_key.rb Normal file
View 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

View file

@ -0,0 +1,12 @@
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
before_create :generate_uuid_v7
private
def generate_uuid_v7
return if self.class.attribute_types["id"].type != :uuid
self.id ||= SecureRandom.uuid_v7
end
end

View file

View file

@ -0,0 +1,44 @@
# frozen_string_literal: true
# (@msw) Stripe-like public IDs that don't require adding a column to the database.
module PublicIdentifiable
SEPARATOR = ?_
extend ActiveSupport::Concern
included do
include Hashid::Rails
class_attribute :public_id_prefix
end
def public_id = "#{self.public_id_prefix}#{SEPARATOR}#{hashid}"
module ClassMethods
def set_public_id_prefix(prefix)
self.public_id_prefix = prefix.to_s.downcase
end
def find_by_public_id(id)
return nil unless id.is_a? String
prefix = id.split(SEPARATOR).first.to_s.downcase
hash = id.split(SEPARATOR).last
return nil unless prefix == self.get_public_id_prefix
find_by_hashid(hash)
end
def find_by_public_id!(id)
obj = find_by_public_id id
raise ActiveRecord::RecordNotFound.new(nil, self.name) if obj.nil?
obj
end
def get_public_id_prefix
return self.public_id_prefix.to_s.downcase if self.public_id_prefix.present?
raise NotImplementedError, "The #{self.class.name} model includes PublicIdentifiable module, but set_public_id_prefix hasn't been called."
end
end
end

79
app/models/doc_page.rb Normal file
View file

@ -0,0 +1,79 @@
# frozen_string_literal: true
class DocPage
DOCS_PATH = Rails.root.join("app/views/docs/pages")
attr_reader :id, :title, :icon, :order, :content
def initialize(id:, title:, icon:, order:, content:)
@id = id
@title = title
@icon = icon
@order = order
@content = content
end
class << self
def all
@all ||= load_all_docs.sort_by(&:order)
end
def find(id)
all.find { |doc| doc.id == id } || raise(ActiveRecord::RecordNotFound, "Doc '#{id}' not found")
end
def reload!
@all = nil
end
private
def load_all_docs
Dir.glob(DOCS_PATH.join("*.md")).map do |file|
parse_doc_file(file)
end
end
def parse_doc_file(file)
id = File.basename(file, ".md")
raw_content = File.read(file)
frontmatter, content = extract_frontmatter(raw_content)
new(
id: id,
title: frontmatter["title"] || id.titleize,
icon: (frontmatter["icon"] || "file").to_sym,
order: frontmatter["order"] || 999,
content: render_markdown(content)
)
end
def extract_frontmatter(content)
if content.start_with?("---")
parts = content.split("---", 3)
if parts.length >= 3
frontmatter = YAML.safe_load(parts[1]) || {}
return [ frontmatter, parts[2].strip ]
end
end
[ {}, content ]
end
def render_markdown(content)
renderer = Redcarpet::Render::HTML.new(
hard_wrap: true,
link_attributes: { target: "_blank", rel: "noopener" }
)
markdown = Redcarpet::Markdown.new(
renderer,
autolink: true,
tables: true,
fenced_code_blocks: true,
strikethrough: true,
highlight: true,
footnotes: true
)
markdown.render(content)
end
end
end

11
app/models/quota.rb Normal file
View file

@ -0,0 +1,11 @@
class Quota
Policy = Data.define(:slug, :max_file_size, :max_total_storage)
ALL_POLICIES = [
Policy[:unverified, 10.megabytes, 50.megabytes],
Policy[:verified, 50.megabytes, 50.gigabytes],
Policy[:functionally_unlimited, 200.megabytes, 300.gigabytes]
].index_by &:slug
def self.policy(slug) = ALL_POLICIES.fetch slug
end

93
app/models/upload.rb Normal file
View file

@ -0,0 +1,93 @@
# frozen_string_literal: true
require "open-uri"
class Upload < ApplicationRecord
include PgSearch::Model
# UUID v7 primary key (automatic via migration)
belongs_to :user
belongs_to :blob, class_name: "ActiveStorage::Blob"
after_destroy :purge_blob
# Delegate file metadata to blob (no duplication!)
delegate :filename, :byte_size, :content_type, :checksum, to: :blob
# Search configuration
pg_search_scope :search_by_filename,
associated_against: {
blob: :filename
},
using: {
tsearch: { prefix: true }
}
pg_search_scope :search,
against: [ :original_url ],
associated_against: {
blob: :filename,
user: [ :email, :name ]
},
using: { tsearch: { prefix: true } }
# Aliases for consistency
alias_method :file_size, :byte_size
alias_method :mime_type, :content_type
# Provenance enum
enum :provenance, {
slack: "slack",
web: "web",
api: "api",
rescued: "rescued"
}, validate: true
validates :provenance, presence: true
scope :recent, -> { order(created_at: :desc) }
scope :by_user, ->(user) { where(user: user) }
scope :today, -> { where("created_at >= ?", Time.zone.now.beginning_of_day) }
scope :this_week, -> { where("created_at >= ?", Time.zone.now.beginning_of_week) }
scope :this_month, -> { where("created_at >= ?", Time.zone.now.beginning_of_month) }
def human_file_size
ActiveSupport::NumberHelper.number_to_human_size(byte_size)
end
# Get CDN URL (uses external uploads controller)
def cdn_url
Rails.application.routes.url_helpers.external_upload_url(
id:,
filename:,
host: ENV["CDN_HOST"] || "cdn.hackclub.com"
)
end
# Create upload from URL (for API/rescue operations)
def self.create_from_url(url, user:, provenance:, original_url: nil, authorization: nil)
open_options = {}
open_options["Authorization"] = authorization if authorization.present?
downloaded = URI.open(url, open_options)
blob = ActiveStorage::Blob.create_and_upload!(
io: downloaded,
filename: File.basename(URI.parse(url).path)
)
create!(
user: user,
blob: blob,
provenance: provenance,
original_url: original_url
)
end
private
def purge_blob
blob.purge
end
end

66
app/models/user.rb Normal file
View file

@ -0,0 +1,66 @@
# frozen_string_literal: true
class User < ApplicationRecord
include PublicIdentifiable
include PgSearch::Model
set_public_id_prefix :usr
def to_param = public_id
pg_search_scope :search,
against: [ :email, :name, :slack_id ],
using: { tsearch: { prefix: true } }
scope :admins, -> { where(is_admin: true) }
validates :hca_id, presence: true, uniqueness: true
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
slack_id = auth.extra.raw_info.slack_id
raise "Missing HCA user ID from authentication" if hca_id.blank?
user = find_by(hca_id:) || find_by(slack_id:)
if user
user.update(
hca_id:,
slack_id:,
email: auth.info.email,
name: auth.info.name,
hca_access_token: auth.credentials.token
)
else
user = create!(
hca_id:,
slack_id:,
email: auth.info.email,
name: auth.info.name,
hca_access_token: auth.credentials.token
)
end
user
end
def hca_profile(access_token) = HCAService.new(access_token).me
def total_files
uploads.count
end
def total_storage_bytes
uploads.joins(:blob).sum("active_storage_blobs.byte_size")
end
def total_storage_gb
(total_storage_bytes / 1.gigabyte.to_f).round(2)
end
def total_storage_formatted
ActiveSupport::NumberHelper.number_to_human_size(total_storage_bytes)
end
end

View 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

View file

@ -0,0 +1,31 @@
# frozen_string_literal: true
class ApplicationPolicy
attr_reader :user, :record
def initialize(user, record)
@user = user
@record = record
end
def index? = false
def show? = false
def create? = false
def new? = create?
def update? = false
def edit? = update?
def destroy? = false
class Scope
def initialize(user, scope)
@user = user
@scope = scope
end
def resolve = raise NotImplementedError, "You must define #resolve in #{self.class}"
private
attr_reader :user, :scope
end
end

View file

@ -0,0 +1,18 @@
# frozen_string_literal: true
class UploadPolicy < ApplicationPolicy
def destroy?
# Users can delete their own uploads, admins can delete any upload
user.is_admin? || record.user_id == user.id
end
class Scope < ApplicationPolicy::Scope
def resolve
if user.is_admin?
scope.all
else
scope.where(user: user)
end
end
end
end

View file

@ -0,0 +1,65 @@
# frozen_string_literal: true
class CDNStatsService
CACHE_KEY_GLOBAL = "cdn:stats:global"
CACHE_DURATION = 5.minutes
# Global stats (cached) - for logged-out users
def self.global_stats
Rails.cache.fetch(CACHE_KEY_GLOBAL, expires_in: CACHE_DURATION) do
calculate_global_stats
end
end
# Force refresh global stats (called by background job)
def self.refresh_global_stats!
Rails.cache.delete(CACHE_KEY_GLOBAL)
global_stats
end
# User stats (live) - for logged-in users
def self.user_stats(user)
quota_service = QuotaService.new(user)
usage = quota_service.current_usage
policy = quota_service.current_policy
used = usage[:storage_used]
max = usage[:storage_limit]
percentage = usage[:percentage_used]
available = [ max - used, 0 ].max
{
total_files: user.total_files,
total_storage: used,
storage_formatted: user.total_storage_formatted,
files_today: user.uploads.today.count,
files_this_week: user.uploads.this_week.count,
recent_uploads: user.uploads.includes(:blob).recent.limit(5),
quota: {
policy: usage[:policy],
storage_limit: max,
available: available,
percentage_used: percentage,
at_warning: usage[:at_warning],
over_quota: usage[:over_quota]
}
}
end
private
def self.calculate_global_stats
total_files = Upload.count
total_storage_bytes = Upload.joins(:blob).sum("active_storage_blobs.byte_size")
total_users = User.joins(:uploads).distinct.count
{
total_files: total_files,
total_storage_bytes: total_storage_bytes,
storage_formatted: ActiveSupport::NumberHelper.number_to_human_size(total_storage_bytes),
total_users: total_users,
files_today: Upload.today.count,
files_this_week: Upload.this_week.count
}
end
end

View file

@ -0,0 +1,241 @@
# frozen_string_literal: true
class FlavorTextService
include ActionView::Helpers::NumberHelper
def initialize(user: nil, env: Rails.env, deterministic: true)
@user = user
@env = env
@seed = deterministic ? Time.now.to_i / 5.minutes : Random.new_seed
@random = Random.new(@seed)
end
def generate
flavor_text = sample
flavor_text = flavor_text.call if flavor_text.respond_to? :call
flavor_text
end
def flavor_texts
[
"bytes bytes bytes bytes bytes",
"A hard drive stuffed with cat photos",
"The Hack Foundation dba The File Store",
"Open on weekends",
"Open on holidays",
"please don't hack",
"Contentedly Delivering Nonsense",
"Cool Dino Network",
"Compressed Data Nuggets",
"Cozy Digital Nest",
"Open late",
"Now in color!",
"Filmed on location",
"Soon to be a major blockchain!",
"As seen on the internet",
"Most viewed site on this domain!",
"Coming to a browser near you",
"#{@random.rand 4..9}0% bug free!",
"#{@random.rand 1..4}0% fewer bugs!",
'Now with "code"',
"Holds lots of bytes",
"Educational!",
"Don't use while driving",
"Support local file hosting!",
"Take frequent breaks!",
"Technically good!",
"Operating at a loss since 2025!",
"Does anyone actually read this?",
"Like and subscribe!",
"As seen on cdn.hackclub.com",
"As seen on hackclub.com",
"Now running in production!",
"put files in computer",
"TODO: get that bread",
"Coming soon to a screen near your face",
"Coming soon to a screen near you",
"As seen on the internet",
"Operating at a loss so you don't have to",
"It holds files!",
"uwu",
"owo",
"ovo",
"An important part of this nutritional breakfast",
"By people with files, for people with files",
'Made using "files"',
"Chosen #1 by dinosaurs everywhere",
"IT departments HATE them",
"Congratulations, you are the #{number_with_delimiter(10**@random.rand(1..5))}th visitor!",
"You've got this",
"Don't forget to drink water!",
"Putting the 'fun' in 'upload'",
"Putting the 'fun' in 'cloud storage'",
"Putting the 'do' in 'download'",
"Putting the 'based' in 'cloud-based hosting'",
"Putting the 'host' in 'ghost'",
"Putting the 'sus' in 'sustainable bandwidth'",
"Open on weekdays!",
"Open on #{Date.today.strftime("%A")}s",
"??? storage!",
"Did you see the size of that #{%w[image video file].sample(random: @random)}?!",
"Guess how much it costs to run this thing!",
"Bytes served fresh daily by Cloudflare",
"Running with Ruby on Rails #{Rails.gem_version.canonical_segments.first}",
"Now with 1% downtime!",
"Achievement unlocked!",
"#{@random.rand(10..50)},#{@random.rand(100..999).to_s.rjust(3, '0')} lines of code",
"Your move, Dropbox",
"If you can read this, the page's status code is 200",
"If you can read this, the page has loaded",
"Now go and upload yourself something nice",
"[Insert splash text here]",
"Condemned by the sheriff of storage",
"Coded on location",
'Voted "3rd"',
"You are now breathing manually",
"If you can read this, thanks!",
"(or similar product)",
"[OK]",
"tell your parents it's educational",
"You found the 3rd Easter egg on the site",
"The best site you're using right now",
"It Is What It Is",
"Made in Vermont, with love",
"Your move S3!",
"Flash plugin failed to load",
"Upload, they said",
"U want sum storage?",
"Check the back of this page for an exclusive promo code!",
"You've found the 5th easter egg on the site!",
"A folder is fine too",
"Welcome to #{%w[data storage].sample(random: @random)} town, population: you",
"So... what's your favorite file format?",
"<span style='font-size: 2px !important'>If you can read this you've got tiny eyes</span>".html_safe,
"Page loaded in: < 24 hrs (I hope)",
"Old and improved!",
"Newly loaded!",
"Refreshing! (if you keep hitting ⌘+R)",
"Recommended by people somewhere!",
"Recommended by people in some places!",
"Recommended by hackers on this site!",
"Recommended by me!",
"Recommended by Hack Club!",
"Recommended by the recommend-o-tron 3000",
"Recommended! (probably)",
"Please stow your files in the upright and locked position",
"Loaded in #{@random.rand(10..35)}ms... jk i don't actually know how long it took",
"Loaded in #{@random.rand(10..35)}ms... jk i can't count",
"Turns out it's hard to make one of these things",
"TODO: come up with some actual jokes for this box",
"asdgfhjdk I'm out of jokes",
"Send your jokes to nora@hackclub.com",
"You're looking great today :)",
"Great! You're here!",
"You need to wake up",
"you need to wake up! Pinch yourself",
"stop dreaming, you need to wake up!",
"Are you suuuuure you aren't a robot?",
"Show emotion here if you aren't a robot",
"Your ad here!",
"Are you feeling lucky?",
"...and you can take that to the cloud",
"Ever just wonder... why?",
"Redstone update out now!",
"educational edition",
"Where's the file lebowski?!",
"We put the 'fun' in 'cloud storage' (there isn't any)",
"Not responsible for any major data loss!",
"In today's internet?!",
"Send us your best haiku!",
"«⋄⇠◇«─◆─»⇢$$$⇠«─◆─»◇⇢⋄»",
"¸¸.•*📁*•.¸¸¸.•*📁*•.¸¸¸.•*📁*•.¸¸¸.•*📁*•.¸",
"◥◤◢◤◢📁📁📁◣◥◣◥◤",
"store no evil",
"byte me",
"not running on the blockchain!",
"not available offline!",
"as seen online",
"online only!",
"new strawberry flavor!",
"same classic taste",
"<marquee scrollamount='5'>📁📁📁</marquee>".html_safe,
-> { "#{@random.rand(5..50)} users online" },
"Raccoon-tested, dinosaur-approved.",
"original recipe!",
"now sugar-free!",
"low-sodium edition",
'we put the ":3" in "S3"!',
"do not adjust your monitor.",
"only #{@random.rand(5..50)} missing #{[ "file", "files" ].sample(random: @random)}!",
"why are you reading these",
"go outside",
"posture check!",
"have you eaten today?",
"blink if you're okay",
"git commit -m 'idk'",
"git commit -m 'stuff'",
"the files understand",
"everything is fine forever",
"yippee!",
"yayyy :3",
":D",
"files :)",
"hehe",
"honk",
"meow",
"AAAAAAAAA",
"help",
"this is a cry for help disguised as a CDN",
"my lawyer advised me not to finish this jo-",
"I can see you",
"behind you",
"the call is coming from inside the server",
"this is your sign",
"you dropped this: 👑",
"I'm in your walls",
"feed me files",
"MORE",
"the prophecy is true",
"the ritual is complete",
"you have been chosen",
"you win!",
"thanks for coming to my ted talk",
"anyway",
"tl;dr: files",
"no but like actually what is a file",
"philosophy major dropout energy",
"the void stares back",
"nothing matters and that's okay",
"we're all just files in the end",
"existential dread as a service",
"powered by anxiety",
"college dropout runs a CDN, more at 11",
"I should be studying",
"due tomorrow? do tomorrow.",
"sleep is for the weak (I am weak)",
"it's 3am and I regret everything",
"made at 4am on a tuesday",
"unmedicated energy",
"no sleep, only code",
"shoutout to my therapist",
"certified mess",
"professionally unprofessional",
"fake it till you make it (we're still faking it)",
"I have no idea what I'm doing",
"stackoverflow raised me",
"ctrl+c ctrl+v my beloved",
"works on my machine (I swear)",
"100% bug-free* *no it's not",
"who let me cook",
"cooked (derogatory)",
"this seemed like a good idea at the time"
]
end
private
def sample
flavor_texts.sample(random: @random)
end
end

View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
class HCAService
BASE_URL = Rails.application.config.hack_club_auth.base_url
def initialize(access_token)
@conn = Faraday.new(url: BASE_URL) do |f|
f.request :json
f.response :json, parser_options: { symbolize_names: true }
f.response :raise_error
f.headers["Authorization"] = "Bearer #{access_token}"
end
end
def me = @conn.get("/api/v1/me").body
def check_verification(idv_id: nil, email: nil, slack_id: nil)
params = { idv_id:, email:, slack_id: }.compact
raise ArgumentError, "Provide one of: idv_id, email, or slack_id" if params.empty?
@conn.get("/api/external/check", params).body
end
end

View file

@ -0,0 +1,111 @@
# frozen_string_literal: true
class QuotaService
WARNING_THRESHOLD_PERCENTAGE = 80
def initialize(user)
@user = user
end
# Returns the applicable Quota::Policy for the user
# Checks HCA if quota_policy is NULL, upgrades to verified if confirmed
def current_policy
if @user.quota_policy.present?
# User has explicit policy set - use it
Quota.policy(@user.quota_policy.to_sym)
else
# No policy set - check HCA verification
if hca_verified?
# User is verified - upgrade them permanently
@user.update_column(:quota_policy, "verified")
Quota.policy(:verified)
else
# Not verified - use unverified tier (don't set field)
Quota.policy(:unverified)
end
end
rescue KeyError
# Invalid policy slug - fall back to unverified
Quota.policy(:unverified)
end
# Returns hash with storage info, policy, and flags
def current_usage
policy = current_policy
used = @user.total_storage_bytes
max = policy.max_total_storage
percentage = percentage_used
{
storage_used: used,
storage_limit: max,
policy: policy.slug.to_s,
percentage_used: percentage,
at_warning: at_warning?,
over_quota: over_quota?
}
end
# Validates if upload is allowed based on file size and total storage
def can_upload?(file_size)
policy = current_policy
# Check file size against per-file limit
return false if file_size > policy.max_file_size
# Check total storage after upload
total_after = @user.total_storage_bytes + file_size
return false if total_after > policy.max_total_storage
true
end
# Boolean if storage exceeded
def over_quota?
@user.total_storage_bytes >= current_policy.max_total_storage
end
# Boolean if >= 80% used
def at_warning?
percentage_used >= WARNING_THRESHOLD_PERCENTAGE
end
# Calculate usage percentage
def percentage_used
max = current_policy.max_total_storage
return 0 if max.zero?
((@user.total_storage_bytes.to_f / max) * 100).round(2)
end
# Check HCA and upgrade to verified if confirmed
# Returns true if verification successful, false otherwise
def check_and_upgrade_verification!
return true if @user.quota_policy.present? # Already has policy set
if hca_verified?
@user.update_column(:quota_policy, "verified")
true
else
false
end
rescue Faraday::Error => e
Rails.logger.warn "HCA verification check failed for user #{@user.id}: #{e.message}"
false
end
private
# Check if user is verified via HCA
def hca_verified?
return false unless @user.hca_access_token.present?
return false unless @user.hca_id.present?
hca = HCAService.new(@user.hca_access_token)
response = hca.check_verification(idv_id: @user.hca_id)
response[:verified] == true
rescue Faraday::Error, ArgumentError => e
Rails.logger.warn "HCA API error for user #{@user.id}: #{e.message}"
false
end
end

View file

@ -0,0 +1 @@
<%= render Components::Admin::Search::Index.new(query: @query, users: @users || [], uploads: @uploads || [], type: params[:type] || "all") %>

View file

@ -0,0 +1 @@
<%= render Components::Admin::Users::Show.new(user: @user) %>

View file

@ -0,0 +1,4 @@
<%= render Components::APIKeys::Index.new(
api_keys: @api_keys,
new_token: flash[:api_key_token]
) %>

12
app/views/base.rb Normal file
View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
class Views::Base < Components::Base
# The `Views::Base` is an abstract class for all your views.
# By default, it inherits from `Components::Base`, but you
# can change that to `Phlex::HTML` if you want to keep views and
# components independent.
# More caching options at https://www.phlex.fun/components/caching
def cache_store = Rails.cache
end

157
app/views/docs/pages/api.md Normal file
View file

@ -0,0 +1,157 @@
---
title: API Documentation
icon: code
order: 3
---
# API Documentation
Upload images programmatically using the CDN API.
## Authentication
Create an API key at [API Keys](/api_keys). Keys are shown once, so copy it immediately.
Include the key in the `Authorization` header:
```
Authorization: Bearer sk_cdn_your_key_here
```
## POST /api/v4/upload
Upload a file via multipart form data.
```bash
curl -X POST \
-H "Authorization: Bearer sk_cdn_your_key_here" \
-F "file=@photo.jpg" \
https://cdn.hackclub.com/api/v4/upload
```
```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 { url } = await response.json();
```
**Response:**
```json
{
"id": "01234567-89ab-cdef-0123-456789abcdef",
"filename": "photo.jpg",
"size": 12345,
"content_type": "image/jpeg",
"url": "https://cdn.hackclub.com/01234567-89ab-cdef-0123-456789abcdef/photo.jpg",
"created_at": "2026-01-29T12:00:00Z"
}
```
## POST /api/v4/upload\_from\_url
Upload an image from a URL.
**Optional header:** `X-Download-Authorization` — passed as `Authorization` when fetching the source URL (useful for protected resources).
```bash
curl -X POST \
-H "Authorization: Bearer sk_cdn_your_key_here" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/image.jpg"}' \
https://cdn.hackclub.com/api/v4/upload_from_url
# With authentication for the source URL:
curl -X POST \
-H "Authorization: Bearer sk_cdn_your_key_here" \
-H "X-Download-Authorization: Bearer source_token_here" \
-H "Content-Type: application/json" \
-d '{"url":"https://protected.example.com/image.jpg"}' \
https://cdn.hackclub.com/api/v4/upload_from_url
```
```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',
// Optional: auth for the source URL
'X-Download-Authorization': 'Bearer source_token_here'
},
body: JSON.stringify({ url: 'https://example.com/image.jpg' })
});
const { url } = await response.json();
```
## GET /api/v4/me
Get the authenticated user and quota information.
```bash
curl -H "Authorization: Bearer sk_cdn_your_key_here" \
https://cdn.hackclub.com/api/v4/me
```
```json
{
"id": "usr_abc123",
"email": "you@hackclub.com",
"name": "Your Name",
"storage_used": 1048576000,
"storage_limit": 53687091200,
"quota_tier": "verified"
}
```
**Quota fields:**
- `storage_used` — bytes used
- `storage_limit` — bytes allowed
- `quota_tier``"unverified"`, `"verified"`, or `"functionally_unlimited"`
## 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:**
```json
{
"error": "Missing file parameter"
}
```
**Quota error (402):**
```json
{
"error": "Storage quota exceeded",
"quota": {
"storage_used": 52428800,
"storage_limit": 52428800,
"quota_tier": "unverified",
"percentage_used": 100.0
}
}
```
See [Storage Quotas](/docs/quotas) for details on getting more space.
## Help
- [#cdn-dev on Slack](https://hackclub.slack.com/archives/C08RYDPS36V)
- [GitHub Issues](https://github.com/hackclub/cdn/issues)

View file

@ -0,0 +1,44 @@
---
title: Getting Started
icon: rocket
order: 1
---
# Getting Started
Hack Club CDN is image hosting for your HTML pages. Upload files, get permanent URLs, embed them anywhere.
## Sign In
Click **Sign in with Hack Club** on the homepage to authenticate.
## Upload an Image
1. Go to **My Files**
2. Drag and drop images or click **Upload**
3. Copy the URL
## Use in HTML
```html
<img src="https://cdn.hackclub.com/019505e2-c85b-7f80-9c31-4b2e5a8d9f12/photo.jpg" alt="My image">
```
## Use in Markdown
```markdown
![](https://cdn.hackclub.com/019505e2-c85b-7f80-9c31-4b2e5a8d9f12/photo.jpg)
```
## Hotlinking
URLs work everywhere - READMEs, personal websites, whatever.
## Programmatic Uploads
Need to upload from code? See the [API documentation](/docs/api).
## Need Help?
- [#cdn-dev on Slack](https://hackclub.slack.com/archives/C08RYDPS36V)
- [GitHub Issues](https://github.com/hackclub/cdn/issues)

View file

@ -0,0 +1,18 @@
---
title: Privacy Policy
icon: shield
order: 5
---
# Privacy Policy
This service is operated by Hack Club. For complete details on how Hack Club handles your data, please see the [Hack Club Privacy Policy](https://hackclub.com/privacy/).
## What CDN Collects
In addition to the standard Hack Club privacy practices, this CDN service stores:
- **Uploaded files**: Content you upload, along with original filenames and file metadata
- **Upload history**: Timestamps and file sizes for quota tracking
We do not analyze the content of your uploads. Files are publicly accessible via their URLs and served directly as uploaded.

View file

@ -0,0 +1,66 @@
---
title: Storage Quotas
icon: database
order: 4
---
# Storage Quotas
CDN provides free storage for the Hack Club community. Your quota depends on whether you're verified.
## What's My Quota?
| Tier | Per File | Total Storage |
|------|----------|---------------|
| **Unverified** | 10 MB | 50 MB |
| **Verified** | 50 MB | 50 GB |
| **Unlimited** | 200 MB | 300 GB |
**New users start unverified.** Once you verify with Hack Club, you automatically get 50GB.
## Get 50GB Free (Verified Tier)
1. Visit [auth.hackclub.com](https://auth.hackclub.com) and submit your ID for verification
2. Wait for HCA ops to approve your ID (usually takes a day or two)
3. Once approved, sign in to CDN again to automatically unlock 50GB
Your quota upgrades automatically once HCA confirms your verification.
## Check Your Usage
Your homepage shows available storage with a progress bar. You'll see warnings when you hit 80% usage, and uploads will be blocked at 100%.
**Via API:**
```bash
curl -H "Authorization: Bearer YOUR_API_KEY" \
https://cdn.hackclub.com/api/v4/me
```
```json
{
"storage_used": 1048576000,
"storage_limit": 53687091200,
"quota_tier": "verified"
}
```
## What Happens When I'm Over Quota?
**Web:** You'll see a red banner and uploads will fail with an error message.
**API:** Returns `402 Payment Required` with quota details:
```json
{
"error": "Storage quota exceeded",
"quota": {
"storage_used": 52428800,
"storage_limit": 52428800,
"quota_tier": "unverified",
"percentage_used": 100.0
}
}
```
Delete some files from **Uploads** to free up space.

View file

@ -0,0 +1,15 @@
---
title: Terms of Service
icon: law
order: 4
---
# Terms of Service
Hack Club CDN is a Hack Club service. By using it, you agree to follow the [Hack Club Code of Conduct](https://hackclub.com/conduct/).
Use this service for personal projects, educational work, and open source stuff. Don't upload anything illegal under US law, harmful, or that you don't have rights to.
We provide this on an "as is" basis and may remove content or suspend accounts that violate the Code of Conduct.
Questions? [#cdn-dev on Slack](https://hackclub.slack.com/archives/C08RYDPS36V) or [nora@hackclub.com](mailto:nora@hackclub.com)

View file

@ -0,0 +1,60 @@
---
title: Using CDN URLs
icon: link
order: 2
---
# Using CDN URLs
## URL Structure
```
https://cdn.hackclub.com/{id}/{filename}
```
Requests are 301 redirected to the underlying storage bucket.
## Embedding
### Images
```html
<img src="https://cdn.hackclub.com/019505e2-c85b-7f80-9c31-4b2e5a8d9f12/photo.jpg" alt="">
```
### Links
```html
<a href="https://cdn.hackclub.com/019505e2-d4a1-7c20-8b45-6e3f2a1c8d09/document.pdf">Download</a>
```
### Markdown
```markdown
![](https://cdn.hackclub.com/019505e2-e7f3-7d40-a156-9c4e8b2d1f03/screenshot.png)
```
## Hotlinking
Supported. URLs can be embedded in GitHub, Notion, Discord, Slack, etc.
## Content-Type
Served based on file extension.
## URL Rescue
Lookup endpoint for files migrated from legacy CDNs:
```
GET /rescue?url={original_url}
```
Examples:
```
/rescue?url=https://hc-cdn.hel1.your-objectstorage.com/s/v3/sdhfksdjfhskdjf.png
/rescue?url=https://cloud-xxxx-hack-club-bot.vercel.app/0awawawa.png
```
Returns 301 redirect to the new CDN URL if found. For image URLs (`.png`, `.jpg`, `.jpeg`), returns an SVG 404 placeholder if not found. Otherwise returns HTTP 404.

View file

@ -0,0 +1,4 @@
<% content_for(:title) { "#{@doc.title} - Docs" } %>
<%= render(Components::HeaderBar.new) unless signed_in? %>
<%= render Components::Docs::Page.new(doc: @doc, docs: @docs) %>

View file

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html data-color-mode="auto" data-light-theme="light" data-dark-theme="dark">
<head>
<title>Something went wrong - CDN</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" href="/icon.png" type="image/png">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: #f6f8fa;
color: #24292f;
}
@media (prefers-color-scheme: dark) {
body { background: #0d1117; color: #c9d1d9; }
.error-box { background: #161b22; border-color: #30363d; }
code { background: #21262d; }
}
.error-box {
text-align: center;
padding: 48px;
background: white;
border: 1px solid #d0d7de;
border-radius: 12px;
max-width: 480px;
margin: 16px;
}
h1 { margin: 0 0 16px; font-size: 24px; }
p { margin: 0 0 16px; color: #656d76; }
code {
display: inline-block;
padding: 4px 12px;
background: #f6f8fa;
border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
font-size: 14px;
}
a { color: #0969da; text-decoration: none; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="error-box">
<h1>Something went wrong</h1>
<% if local_assigns[:error_message] && error_message.present? %>
<p><%= error_message %></p>
<% else %>
<p>We've been notified and are looking into it.</p>
<% end %>
<% if local_assigns[:error_id] && error_id.present? %>
<p>
Error ID: <code><%= error_id %></code>
</p>
<% end %>
<p><a href="/">← Back to home</a></p>
</div>
</body>
</html>

View file

@ -0,0 +1,53 @@
<!DOCTYPE html>
<html data-color-mode="auto" data-light-theme="light" data-dark-theme="dark">
<head>
<title><%= content_for(:title) || "CDN" %></title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= yield :head %>
<%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
<%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
<link rel="icon" href="/icon.png" type="image/png">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/icon.png">
<%# Includes all stylesheet files in app/assets/stylesheets %>
<%= vite_stylesheet_tag "application.scss" %>
<%= vite_client_tag %>
<%= vite_javascript_tag 'application' %>
<!--
If using a TypeScript entrypoint file:
vite_typescript_tag 'application'
If using a .jsx or .tsx entrypoint, add the extension:
vite_javascript_tag 'application.jsx'
Visit the guide for more information: https://vite-ruby.netlify.app/guide/rails
-->
</head>
<body>
<%= render(Components::HeaderBar.new) if signed_in? %>
<% if signed_in? %>
<%= quota_banner_for(current_user) %>
<% end %>
<% if flash[:notice] || flash[:alert] %>
<div style="max-width: 768px; margin: 16px auto; padding: 0 16px;">
<% if flash[:notice] %>
<%= render(Primer::Beta::Flash.new(scheme: :success, icon: :check)) { flash[:notice] } %>
<% end %>
<% if flash[:alert] %>
<%= render(Primer::Beta::Flash.new(scheme: :danger, icon: :alert)) { flash[:alert] } %>
<% end %>
</div>
<% end %>
<%= yield %>
</body>
</html>

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style>
/* Email styles need to be inline */
</style>
</head>
<body>
<%= yield %>
</body>
</html>

View file

@ -0,0 +1 @@
<%= yield %>

View file

@ -0,0 +1,22 @@
{
"name": "CDN",
"icons": [
{
"src": "/icon.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "/icon.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "maskable"
}
],
"start_url": "/",
"display": "standalone",
"scope": "/",
"description": "CDN.",
"theme_color": "red",
"background_color": "red"
}

View file

@ -0,0 +1,26 @@
// Add a service worker for processing Web Push notifications:
//
// self.addEventListener("push", async (event) => {
// const { title, options } = await event.data.json()
// event.waitUntil(self.registration.showNotification(title, options))
// })
//
// self.addEventListener("notificationclick", function(event) {
// event.notification.close()
// event.waitUntil(
// clients.matchAll({ type: "window" }).then((clientList) => {
// for (let i = 0; i < clientList.length; i++) {
// let client = clientList[i]
// let clientPath = (new URL(client.url)).pathname
//
// if (clientPath == event.notification.data.path && "focus" in client) {
// return client.focus()
// }
// }
//
// if (clients.openWindow) {
// return clients.openWindow(event.notification.data.path)
// }
// })
// )
// })

View file

@ -0,0 +1,5 @@
<% if signed_in? %>
<%= render Components::StaticPages::Home.new(stats: @user_stats, user: current_user, flavor_text: @flavor_text) %>
<% else %>
<%= render Components::StaticPages::LoggedOut.new(stats: @global_stats, flavor_text: @flavor_text) %>
<% end %>

View file

@ -0,0 +1 @@
<%= render Components::Uploads::Index.new(uploads: @uploads, query: params[:query]) %>

7
bin/brakeman Executable file
View file

@ -0,0 +1,7 @@
#!/usr/bin/env ruby
require "rubygems"
require "bundler/setup"
ARGV.unshift("--ensure-latest")
load Gem.bin_path("brakeman", "brakeman")

2
bin/dev Executable file
View file

@ -0,0 +1,2 @@
#!/usr/bin/env ruby
exec "./bin/rails", "server", *ARGV

14
bin/docker-entrypoint Executable file
View file

@ -0,0 +1,14 @@
#!/bin/bash -e
# Enable jemalloc for reduced memory usage and latency.
if [ -z "${LD_PRELOAD+x}" ]; then
LD_PRELOAD=$(find /usr/lib -name libjemalloc.so.2 -print -quit)
export LD_PRELOAD
fi
# If running the rails server then create or migrate existing database
if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then
./bin/rails db:prepare
fi
exec "${@}"

6
bin/jobs Executable file
View file

@ -0,0 +1,6 @@
#!/usr/bin/env ruby
require_relative "../config/environment"
require "solid_queue/cli"
SolidQueue::Cli.start(ARGV)

4
bin/rails Executable file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env ruby
APP_PATH = File.expand_path("../config/application", __dir__)
require_relative "../config/boot"
require "rails/commands"

4
bin/rake Executable file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env ruby
require_relative "../config/boot"
require "rake"
Rake.application.run

8
bin/rubocop Executable file
View file

@ -0,0 +1,8 @@
#!/usr/bin/env ruby
require "rubygems"
require "bundler/setup"
# explicit rubocop config increases performance slightly while avoiding config confusion.
ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__))
load Gem.bin_path("rubocop", "rubocop")

Some files were not shown because too many files have changed in this diff Show more