mirror of
https://github.com/System-End/cdn.git
synced 2026-04-19 15:18:15 +00:00
CDN V4 (#20)
This commit is contained in:
parent
20ab70a936
commit
ae63185445
193 changed files with 7573 additions and 1169 deletions
47
.dockerignore
Normal file
47
.dockerignore
Normal 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*
|
||||
67
.env.example
67
.env.example
|
|
@ -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
9
.gitattributes
vendored
Normal 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
12
.github/dependabot.yml
vendored
Normal 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
38
.github/workflows/ci.yml
vendored
Normal 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
50
.gitignore
vendored
|
|
@ -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
8
.rubocop.yml
Normal 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
1
.ruby-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
ruby-3.4.4
|
||||
88
Dockerfile
88
Dockerfile
|
|
@ -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
72
Gemfile
Normal 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
497
Gemfile.lock
Normal 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
3
Procfile.dev
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
|
||||
vite: bin/vite dev
|
||||
web: bin/rails s
|
||||
188
README.md
188
README.md
|
|
@ -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
6
Rakefile
Normal 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
|
||||
144
app/components/admin/search/index.rb
Normal file
144
app/components/admin/search/index.rb
Normal 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
|
||||
197
app/components/admin/users/show.rb
Normal file
197
app/components/admin/users/show.rb
Normal 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
|
||||
55
app/components/api_keys/_row.rb
Normal file
55
app/components/api_keys/_row.rb
Normal 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
|
||||
101
app/components/api_keys/index.rb
Normal file
101
app/components/api_keys/index.rb
Normal 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
17
app/components/base.rb
Normal 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
|
||||
32
app/components/docs/content.rb
Normal file
32
app/components/docs/content.rb
Normal 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
|
||||
17
app/components/docs/page.rb
Normal file
17
app/components/docs/page.rb
Normal 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
|
||||
36
app/components/docs/sidebar.rb
Normal file
36
app/components/docs/sidebar.rb
Normal 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
|
||||
41
app/components/header_bar.rb
Normal file
41
app/components/header_bar.rb
Normal 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
|
||||
28
app/components/inspector.rb
Normal file
28
app/components/inspector.rb
Normal 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
|
||||
51
app/components/static_pages/base.rb
Normal file
51
app/components/static_pages/base.rb
Normal 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
|
||||
128
app/components/static_pages/home.rb
Normal file
128
app/components/static_pages/home.rb
Normal 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
|
||||
76
app/components/static_pages/logged_out.rb
Normal file
76
app/components/static_pages/logged_out.rb
Normal 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
|
||||
118
app/components/uploads/_row.rb
Normal file
118
app/components/uploads/_row.rb
Normal 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
|
||||
107
app/components/uploads/index.rb
Normal file
107
app/components/uploads/index.rb
Normal 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
|
||||
12
app/controllers/admin/api_keys_controller.rb
Normal file
12
app/controllers/admin/api_keys_controller.rb
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
class APIKeysController < ApplicationController
|
||||
def destroy
|
||||
api_key = APIKey.find(params[:id])
|
||||
user = api_key.user
|
||||
api_key.revoke!
|
||||
redirect_to admin_user_path(user), notice: "API key '#{api_key.name}' revoked."
|
||||
end
|
||||
end
|
||||
end
|
||||
14
app/controllers/admin/application_controller.rb
Normal file
14
app/controllers/admin/application_controller.rb
Normal 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
|
||||
24
app/controllers/admin/search_controller.rb
Normal file
24
app/controllers/admin/search_controller.rb
Normal 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
|
||||
19
app/controllers/admin/uploads_controller.rb
Normal file
19
app/controllers/admin/uploads_controller.rb
Normal 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
|
||||
40
app/controllers/admin/users_controller.rb
Normal file
40
app/controllers/admin/users_controller.rb
Normal 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
|
||||
22
app/controllers/api/v4/api_keys_controller.rb
Normal file
22
app/controllers/api/v4/api_keys_controller.rb
Normal 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
|
||||
53
app/controllers/api/v4/application_controller.rb
Normal file
53
app/controllers/api/v4/application_controller.rb
Normal 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
|
||||
108
app/controllers/api/v4/uploads_controller.rb
Normal file
108
app/controllers/api/v4/uploads_controller.rb
Normal 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
|
||||
21
app/controllers/api/v4/users_controller.rb
Normal file
21
app/controllers/api/v4/users_controller.rb
Normal 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
|
||||
36
app/controllers/api_keys_controller.rb
Normal file
36
app/controllers/api_keys_controller.rb
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class APIKeysController < ApplicationController
|
||||
before_action :set_api_key, only: [ :destroy ]
|
||||
|
||||
def index
|
||||
@api_keys = current_user.api_keys.active.recent
|
||||
end
|
||||
|
||||
def create
|
||||
@api_key = current_user.api_keys.create!(api_key_params)
|
||||
|
||||
flash[:api_key_token] = @api_key.token
|
||||
redirect_to api_keys_path, notice: "API key created. Copy it now - you won't see it again!"
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
redirect_to api_keys_path, alert: "Failed to create API key: #{e.message}"
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @api_key, :destroy?
|
||||
@api_key.revoke!
|
||||
redirect_to api_keys_path, notice: "API key revoked successfully."
|
||||
rescue Pundit::NotAuthorizedError
|
||||
redirect_to api_keys_path, alert: "You are not authorized to revoke this API key."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_api_key
|
||||
@api_key = APIKey.find(params[:id])
|
||||
end
|
||||
|
||||
def api_key_params
|
||||
params.require(:api_key).permit(:name)
|
||||
end
|
||||
end
|
||||
55
app/controllers/application_controller.rb
Normal file
55
app/controllers/application_controller.rb
Normal 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
|
||||
0
app/controllers/concerns/.keep
Normal file
0
app/controllers/concerns/.keep
Normal file
23
app/controllers/docs_controller.rb
Normal file
23
app/controllers/docs_controller.rb
Normal 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
|
||||
62
app/controllers/external_uploads_controller.rb
Normal file
62
app/controllers/external_uploads_controller.rb
Normal 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
|
||||
25
app/controllers/sessions_controller.rb
Normal file
25
app/controllers/sessions_controller.rb
Normal 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
|
||||
12
app/controllers/static_pages_controller.rb
Normal file
12
app/controllers/static_pages_controller.rb
Normal 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
|
||||
77
app/controllers/uploads_controller.rb
Normal file
77
app/controllers/uploads_controller.rb
Normal 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
|
||||
112
app/frontend/controllers/upload_dropzone.js
Normal file
112
app/frontend/controllers/upload_dropzone.js
Normal 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);
|
||||
})();
|
||||
5
app/frontend/entrypoints/application.js
Normal file
5
app/frontend/entrypoints/application.js
Normal 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";
|
||||
22
app/frontend/entrypoints/application.scss
Normal file
22
app/frontend/entrypoints/application.scss
Normal 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);
|
||||
}
|
||||
9
app/frontend/styles/admin_tool.scss
Normal file
9
app/frontend/styles/admin_tool.scss
Normal 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;
|
||||
}
|
||||
0
app/frontend/styles/dark_mode.scss
Normal file
0
app/frontend/styles/dark_mode.scss
Normal file
99
app/frontend/styles/file_dropzone.scss
Normal file
99
app/frontend/styles/file_dropzone.scss
Normal 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%);
|
||||
}
|
||||
}
|
||||
0
app/frontend/styles/hca.scss
Normal file
0
app/frontend/styles/hca.scss
Normal file
6
app/helpers/application_helper.rb
Normal file
6
app/helpers/application_helper.rb
Normal 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
|
||||
29
app/helpers/quota_helper.rb
Normal file
29
app/helpers/quota_helper.rb
Normal 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
|
||||
2
app/helpers/static_pages_helper.rb
Normal file
2
app/helpers/static_pages_helper.rb
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
module StaticPagesHelper
|
||||
end
|
||||
7
app/jobs/application_job.rb
Normal file
7
app/jobs/application_job.rb
Normal 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
|
||||
9
app/jobs/refresh_cdn_stats_job.rb
Normal file
9
app/jobs/refresh_cdn_stats_job.rb
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class RefreshCDNStatsJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform
|
||||
CDNStatsService.refresh_global_stats!
|
||||
end
|
||||
end
|
||||
4
app/mailers/application_mailer.rb
Normal file
4
app/mailers/application_mailer.rb
Normal 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
45
app/models/api_key.rb
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class APIKey < ApplicationRecord
|
||||
belongs_to :user
|
||||
|
||||
# Lockbox encryption
|
||||
has_encrypted :token
|
||||
|
||||
# Blind index for token lookup
|
||||
blind_index :token
|
||||
|
||||
before_validation :generate_token, on: :create
|
||||
|
||||
validates :name, presence: true, length: { maximum: 255 }
|
||||
|
||||
scope :active, -> { where(revoked: false) }
|
||||
scope :recent, -> { order(created_at: :desc) }
|
||||
|
||||
# Find by token using blind index
|
||||
def self.find_by_token(token)
|
||||
find_by(token: token) # Blind index handles lookup
|
||||
end
|
||||
|
||||
def revoke!
|
||||
update!(revoked: true, revoked_at: Time.current)
|
||||
end
|
||||
|
||||
def active?
|
||||
!revoked
|
||||
end
|
||||
|
||||
def masked_token
|
||||
# Decrypt to get the full token, then mask it
|
||||
full = token
|
||||
prefix = full[0...13] # "sk_cdn_" + first 6 chars
|
||||
suffix = full[-6..] # Last 6 chars
|
||||
"#{prefix}....#{suffix}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_token
|
||||
self.token ||= "sk_cdn_#{SecureRandom.hex(32)}"
|
||||
end
|
||||
end
|
||||
12
app/models/application_record.rb
Normal file
12
app/models/application_record.rb
Normal 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
|
||||
0
app/models/concerns/.keep
Normal file
0
app/models/concerns/.keep
Normal file
44
app/models/concerns/public_identifiable.rb
Normal file
44
app/models/concerns/public_identifiable.rb
Normal 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
79
app/models/doc_page.rb
Normal 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
11
app/models/quota.rb
Normal 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
93
app/models/upload.rb
Normal 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
66
app/models/user.rb
Normal 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
|
||||
16
app/policies/api_key_policy.rb
Normal file
16
app/policies/api_key_policy.rb
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class APIKeyPolicy < ApplicationPolicy
|
||||
def index? = true
|
||||
def create? = true
|
||||
|
||||
def destroy?
|
||||
user.is_admin? || record.user_id == user.id
|
||||
end
|
||||
|
||||
class Scope < ApplicationPolicy::Scope
|
||||
def resolve
|
||||
user.is_admin? ? scope.all : scope.where(user: user)
|
||||
end
|
||||
end
|
||||
end
|
||||
31
app/policies/application_policy.rb
Normal file
31
app/policies/application_policy.rb
Normal 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
|
||||
18
app/policies/upload_policy.rb
Normal file
18
app/policies/upload_policy.rb
Normal 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
|
||||
65
app/services/cdn_stats_service.rb
Normal file
65
app/services/cdn_stats_service.rb
Normal 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
|
||||
241
app/services/flavor_text_service.rb
Normal file
241
app/services/flavor_text_service.rb
Normal 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
|
||||
23
app/services/hca_service.rb
Normal file
23
app/services/hca_service.rb
Normal 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
|
||||
111
app/services/quota_service.rb
Normal file
111
app/services/quota_service.rb
Normal 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
|
||||
1
app/views/admin/search/index.html.erb
Normal file
1
app/views/admin/search/index.html.erb
Normal file
|
|
@ -0,0 +1 @@
|
|||
<%= render Components::Admin::Search::Index.new(query: @query, users: @users || [], uploads: @uploads || [], type: params[:type] || "all") %>
|
||||
1
app/views/admin/users/show.html.erb
Normal file
1
app/views/admin/users/show.html.erb
Normal file
|
|
@ -0,0 +1 @@
|
|||
<%= render Components::Admin::Users::Show.new(user: @user) %>
|
||||
4
app/views/api_keys/index.html.erb
Normal file
4
app/views/api_keys/index.html.erb
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<%= render Components::APIKeys::Index.new(
|
||||
api_keys: @api_keys,
|
||||
new_token: flash[:api_key_token]
|
||||
) %>
|
||||
12
app/views/base.rb
Normal file
12
app/views/base.rb
Normal 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
157
app/views/docs/pages/api.md
Normal 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)
|
||||
44
app/views/docs/pages/getting-started.md
Normal file
44
app/views/docs/pages/getting-started.md
Normal 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
|
||||

|
||||
```
|
||||
|
||||
## 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)
|
||||
18
app/views/docs/pages/privacy.md
Normal file
18
app/views/docs/pages/privacy.md
Normal 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.
|
||||
66
app/views/docs/pages/quotas.md
Normal file
66
app/views/docs/pages/quotas.md
Normal 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.
|
||||
15
app/views/docs/pages/terms.md
Normal file
15
app/views/docs/pages/terms.md
Normal 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)
|
||||
60
app/views/docs/pages/using-cdn-urls.md
Normal file
60
app/views/docs/pages/using-cdn-urls.md
Normal 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
|
||||

|
||||
```
|
||||
|
||||
## 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.
|
||||
4
app/views/docs/show.html.erb
Normal file
4
app/views/docs/show.html.erb
Normal 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) %>
|
||||
62
app/views/errors/internal_server_error.html.erb
Normal file
62
app/views/errors/internal_server_error.html.erb
Normal 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>
|
||||
53
app/views/layouts/application.html.erb
Normal file
53
app/views/layouts/application.html.erb
Normal 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>
|
||||
13
app/views/layouts/mailer.html.erb
Normal file
13
app/views/layouts/mailer.html.erb
Normal 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>
|
||||
1
app/views/layouts/mailer.text.erb
Normal file
1
app/views/layouts/mailer.text.erb
Normal file
|
|
@ -0,0 +1 @@
|
|||
<%= yield %>
|
||||
22
app/views/pwa/manifest.json.erb
Normal file
22
app/views/pwa/manifest.json.erb
Normal 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"
|
||||
}
|
||||
26
app/views/pwa/service-worker.js
Normal file
26
app/views/pwa/service-worker.js
Normal 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)
|
||||
// }
|
||||
// })
|
||||
// )
|
||||
// })
|
||||
5
app/views/static_pages/home.html.erb
Normal file
5
app/views/static_pages/home.html.erb
Normal 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 %>
|
||||
1
app/views/uploads/index.html.erb
Normal file
1
app/views/uploads/index.html.erb
Normal file
|
|
@ -0,0 +1 @@
|
|||
<%= render Components::Uploads::Index.new(uploads: @uploads, query: params[:query]) %>
|
||||
7
bin/brakeman
Executable file
7
bin/brakeman
Executable 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
2
bin/dev
Executable file
|
|
@ -0,0 +1,2 @@
|
|||
#!/usr/bin/env ruby
|
||||
exec "./bin/rails", "server", *ARGV
|
||||
14
bin/docker-entrypoint
Executable file
14
bin/docker-entrypoint
Executable 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
6
bin/jobs
Executable 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
4
bin/rails
Executable 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
4
bin/rake
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
#!/usr/bin/env ruby
|
||||
require_relative "../config/boot"
|
||||
require "rake"
|
||||
Rake.application.run
|
||||
8
bin/rubocop
Executable file
8
bin/rubocop
Executable 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
Loading…
Add table
Reference in a new issue