viridis quota

This commit is contained in:
24c02 2026-01-30 12:19:02 -05:00
parent 205e247770
commit fdf8dfab72
17 changed files with 510 additions and 13 deletions

View file

@ -12,6 +12,7 @@ class Components::Admin::Users::Show < Components::Base
div(style: "max-width: 800px; margin: 0 auto; padding: 24px;") do
header_section
stats_section
quota_section
api_keys_section
uploads_section
end
@ -66,6 +67,82 @@ class Components::Admin::Users::Show < Components::Base
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?

View file

@ -33,13 +33,13 @@ class Components::StaticPages::Home < Components::StaticPages::Base
end
div(style: "display: flex; gap: 8px; flex-wrap: wrap;") do
a(href: "https://github.com/hackclub/cdn", target: "_blank", rel: "noopener", class: "btn") do
render Primer::Beta::Octicon.new(icon: :"mark-github", mr: 1)
plain "View on GitHub"
a(href: helpers.docs_path("getting-started"), class: "btn") do
render Primer::Beta::Octicon.new(icon: :book, mr: 1)
plain "Docs"
end
a(href: "https://app.slack.com/client/T0266FRGM/C016DEDUL87", target: "_blank", rel: "noopener", class: "btn btn-primary") do
render Primer::Beta::Octicon.new(icon: :"comment-discussion", mr: 1)
plain "Join #cdn"
a(href: helpers.uploads_path, class: "btn btn-primary") do
render Primer::Beta::Octicon.new(icon: :upload, mr: 1)
plain "Upload"
end
end
end
@ -54,6 +54,7 @@ class Components::StaticPages::Home < Components::StaticPages::Base
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
@ -74,6 +75,51 @@ class Components::StaticPages::Home < Components::StaticPages::Base
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

View file

@ -12,6 +12,25 @@ module Admin
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

View file

@ -3,6 +3,8 @@
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]
@ -37,6 +39,17 @@ module API
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
@ -44,6 +57,42 @@ module API
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
return
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,

View file

@ -4,10 +4,16 @@ 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
name: current_user.name,
storage_used: usage[:storage_used],
storage_limit: usage[:storage_limit],
quota_tier: usage[:policy]
}
end
end

View file

@ -8,6 +8,9 @@ class SessionsController < ApplicationController
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

View file

@ -2,6 +2,7 @@
class UploadsController < ApplicationController
before_action :set_upload, only: [:destroy]
before_action :check_quota, only: [:create]
def index
@uploads = current_user.uploads.includes(:blob).recent
@ -48,6 +49,28 @@ class UploadsController < ApplicationController
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])}."
return
end
end
def set_upload
@upload = Upload.find(params[:id])
end

View file

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

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

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

View file

@ -19,13 +19,30 @@ class CDNStatsService
# 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: user.total_storage_bytes,
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)
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

View file

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

View file

@ -94,7 +94,7 @@ const { url } = await response.json();
## GET /api/v4/me
Get the authenticated user.
Get the authenticated user and quota information.
```bash
curl -H "Authorization: Bearer sk_cdn_your_key_here" \
@ -105,25 +105,52 @@ curl -H "Authorization: Bearer sk_cdn_your_key_here" \
{
"id": "usr_abc123",
"email": "you@hackclub.com",
"name": "Your Name"
"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)

View file

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

View file

@ -35,6 +35,9 @@
<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] %>

View file

@ -1,7 +1,11 @@
Rails.application.routes.draw do
namespace :admin do
get "search", to: "search#index"
resources :users, only: [:show, :destroy]
resources :users, only: [:show, :destroy] do
member do
patch "set_quota"
end
end
resources :uploads, only: [:destroy]
resources :api_keys, only: [:destroy]
end

View file

@ -0,0 +1,5 @@
class AddQuotaPolicyToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :quota_policy, :string
end
end

3
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_01_29_201832) do
ActiveRecord::Schema[8.0].define(version: 2026_01_30_161152) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@ -79,6 +79,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_01_29_201832) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "slack_id"
t.string "quota_policy"
t.index ["hca_id"], name: "index_users_on_hca_id", unique: true
t.index ["slack_id"], name: "index_users_on_slack_id", unique: true
end