diff --git a/app/components/admin/users/show.rb b/app/components/admin/users/show.rb index 81d4f15..04ae13b 100644 --- a/app/components/admin/users/show.rb +++ b/app/components/admin/users/show.rb @@ -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? diff --git a/app/components/static_pages/home.rb b/app/components/static_pages/home.rb index e7435e7..f325416 100644 --- a/app/components/static_pages/home.rb +++ b/app/components/static_pages/home.rb @@ -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 diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 85ea688..4975a87 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -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 diff --git a/app/controllers/api/v4/uploads_controller.rb b/app/controllers/api/v4/uploads_controller.rb index cdea115..3051181 100644 --- a/app/controllers/api/v4/uploads_controller.rb +++ b/app/controllers/api/v4/uploads_controller.rb @@ -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, diff --git a/app/controllers/api/v4/users_controller.rb b/app/controllers/api/v4/users_controller.rb index 1920e32..342a86a 100644 --- a/app/controllers/api/v4/users_controller.rb +++ b/app/controllers/api/v4/users_controller.rb @@ -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 diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index a0f6743..2c1ff6c 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -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 diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index 1b83f36..03191ca 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -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 diff --git a/app/helpers/quota_helper.rb b/app/helpers/quota_helper.rb new file mode 100644 index 0000000..7e107a2 --- /dev/null +++ b/app/helpers/quota_helper.rb @@ -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 diff --git a/app/models/quota.rb b/app/models/quota.rb new file mode 100644 index 0000000..fcb8683 --- /dev/null +++ b/app/models/quota.rb @@ -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 \ No newline at end of file diff --git a/app/services/cdn_stats_service.rb b/app/services/cdn_stats_service.rb index de76df8..53e49e3 100644 --- a/app/services/cdn_stats_service.rb +++ b/app/services/cdn_stats_service.rb @@ -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 diff --git a/app/services/quota_service.rb b/app/services/quota_service.rb new file mode 100644 index 0000000..f05a62c --- /dev/null +++ b/app/services/quota_service.rb @@ -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 diff --git a/app/views/docs/pages/api.md b/app/views/docs/pages/api.md index 513610f..9246917 100644 --- a/app/views/docs/pages/api.md +++ b/app/views/docs/pages/api.md @@ -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) diff --git a/app/views/docs/pages/quotas.md b/app/views/docs/pages/quotas.md new file mode 100644 index 0000000..922b14b --- /dev/null +++ b/app/views/docs/pages/quotas.md @@ -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. \ No newline at end of file diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 9ad7df4..b56e884 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -35,6 +35,9 @@
<%= render(Components::HeaderBar.new) if signed_in? %> + <% if signed_in? %> + <%= quota_banner_for(current_user) %> + <% end %> <% if flash[:notice] || flash[:alert] %>