mirror of
https://github.com/System-End/cdn.git
synced 2026-04-19 20:55:10 +00:00
viridis quota
This commit is contained in:
parent
205e247770
commit
fdf8dfab72
17 changed files with 510 additions and 13 deletions
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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
|
||||
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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
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.
|
||||
|
|
@ -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] %>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
5
db/migrate/20260130161152_add_quota_policy_to_users.rb
Normal file
5
db/migrate/20260130161152_add_quota_policy_to_users.rb
Normal 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
3
db/schema.rb
generated
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue