From 5ce2ea55adf611ebddd24f769ca7211f3388e65a Mon Sep 17 00:00:00 2001 From: 24c02 <163450896+24c02@users.noreply.github.com> Date: Thu, 29 Jan 2026 00:51:48 -0500 Subject: [PATCH] whole buncha stuff --- app/components/base.rb | 2 + app/components/header_bar.rb | 2 +- app/components/static_pages/base.rb | 51 +++++++++++ app/components/static_pages/home.rb | 91 +++++++++++++++++++ app/components/static_pages/logged_out.rb | 74 +++++++++++++++ app/controllers/sessions_controller.rb | 2 +- app/controllers/static_pages_controller.rb | 5 + app/frontend/entrypoints/application.js | 3 + app/jobs/refresh_cdn_stats_job.rb | 9 ++ app/models/application_record.rb | 9 ++ app/models/upload.rb | 57 ++++++++++++ app/models/user.rb | 14 +++ app/services/cdn_stats_service.rb | 49 ++++++++++ app/views/layouts/application.html.erb | 2 +- app/views/static_pages/home.html.erb | 6 +- config/environments/production.rb | 4 +- config/recurring.yml | 3 + config/storage.yml | 16 ++-- ...te_active_storage_tables.active_storage.rb | 57 ++++++++++++ db/migrate/20260129051531_create_uploads.rb | 20 ++++ db/schema.rb | 49 +++++++++- db/seeds.rb | 38 ++++++++ package.json | 4 +- vite.config.ts | 8 ++ yarn.lock | 10 ++ 25 files changed, 568 insertions(+), 17 deletions(-) create mode 100644 app/components/static_pages/base.rb create mode 100644 app/components/static_pages/home.rb create mode 100644 app/components/static_pages/logged_out.rb create mode 100644 app/jobs/refresh_cdn_stats_job.rb create mode 100644 app/models/upload.rb create mode 100644 app/services/cdn_stats_service.rb create mode 100644 db/migrate/20260129051530_create_active_storage_tables.active_storage.rb create mode 100644 db/migrate/20260129051531_create_uploads.rb diff --git a/app/components/base.rb b/app/components/base.rb index 2ae999a..884140d 100644 --- a/app/components/base.rb +++ b/app/components/base.rb @@ -5,6 +5,8 @@ class Components::Base < Phlex::HTML 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 diff --git a/app/components/header_bar.rb b/app/components/header_bar.rb index 9b9cbad..fdfc0a9 100644 --- a/app/components/header_bar.rb +++ b/app/components/header_bar.rb @@ -22,7 +22,7 @@ class Components::HeaderBar < Components::Base plain current_user.name end - menu.with_item(label: "Log out", href: logout_path, data: { method: :delete }) do |item| + 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 diff --git a/app/components/static_pages/base.rb b/app/components/static_pages/base.rb new file mode 100644 index 0000000..31b0a69 --- /dev/null +++ b/app/components/static_pages/base.rb @@ -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: "API Docs", href: "https://github.com/hackclub/cdn#-api-usage", 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 diff --git a/app/components/static_pages/home.rb b/app/components/static_pages/home.rb new file mode 100644 index 0000000..c4f71d4 --- /dev/null +++ b/app/components/static_pages/home.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +class Components::StaticPages::Home < Components::StaticPages::Base + def initialize(stats:, user:) + @stats = stats + @user = user + 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 + + 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;") { "Your CDN Stash" } + 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" + 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" + 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) + 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 + div(style: "background: var(--bgColor-default, #fff); border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px; overflow: hidden;") do + stats[:recent_uploads].each_with_index do |upload, index| + div(style: "padding: 12px 16px; #{index > 0 ? 'border-top: 1px solid var(--borderColor-default, #d0d7de);' : ''}") do + div(style: "display: flex; justify-content: space-between; align-items: center; gap: 16px;") do + 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, 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 + a(href: upload.cdn_url, target: "_blank", rel: "noopener", class: "btn btn-sm") do + plain "View" + end + end + end + 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 diff --git a/app/components/static_pages/logged_out.rb b/app/components/static_pages/logged_out.rb new file mode 100644 index 0000000..6f5e574 --- /dev/null +++ b/app/components/static_pages/logged_out.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +class Components::StaticPages::LoggedOut < Components::StaticPages::Base + def initialize(stats:) + @stats = stats + 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 + + 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; max-width: 600px;") do + plain "File hosting for Hack Clubbers." + end + 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 diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 4c1c487..a0f6743 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -13,7 +13,7 @@ class SessionsController < ApplicationController def destroy reset_session - redirect_to login_path, notice: "Signed out successfully!" + redirect_to root_path, notice: "Signed out successfully!" end def failure diff --git a/app/controllers/static_pages_controller.rb b/app/controllers/static_pages_controller.rb index 81836f8..8042e07 100644 --- a/app/controllers/static_pages_controller.rb +++ b/app/controllers/static_pages_controller.rb @@ -2,5 +2,10 @@ class StaticPagesController < ApplicationController skip_before_action :require_authentication!, only: [:home] def home + if signed_in? + @user_stats = CDNStatsService.user_stats(current_user) + else + @global_stats = CDNStatsService.global_stats + end end end diff --git a/app/frontend/entrypoints/application.js b/app/frontend/entrypoints/application.js index c021a6f..0b2abd5 100644 --- a/app/frontend/entrypoints/application.js +++ b/app/frontend/entrypoints/application.js @@ -1 +1,4 @@ +import Rails from "@rails/ujs"; +Rails.start(); import "@primer/view-components/app/components/primer/primer.js"; + diff --git a/app/jobs/refresh_cdn_stats_job.rb b/app/jobs/refresh_cdn_stats_job.rb new file mode 100644 index 0000000..1c6e3d2 --- /dev/null +++ b/app/jobs/refresh_cdn_stats_job.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class RefreshCDNStatsJob < ApplicationJob + queue_as :default + + def perform + CDNStatsService.refresh_global_stats! + end +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index b63caeb..617a8f7 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -1,3 +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 diff --git a/app/models/upload.rb b/app/models/upload.rb new file mode 100644 index 0000000..7db6ec4 --- /dev/null +++ b/app/models/upload.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class Upload < ApplicationRecord + # UUID v7 primary key (automatic via migration) + + belongs_to :user + belongs_to :blob, class_name: 'ActiveStorage::Blob' + + # Delegate file metadata to blob (no duplication!) + delegate :filename, :byte_size, :content_type, :checksum, to: :blob + + # 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 (generated from blob) + def cdn_url + Rails.application.routes.url_helpers.rails_blob_url(blob, 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) + downloaded = URI.open(url) + + 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 +end diff --git a/app/models/user.rb b/app/models/user.rb index edf4fbd..9682ad2 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -9,6 +9,8 @@ class User < ApplicationRecord validates :hca_id, presence: true, uniqueness: true encrypts :hca_access_token + has_many :uploads, dependent: :destroy + def self.find_or_create_from_omniauth(auth) hca_id = auth.uid slack_id = auth.extra.raw_info.slack_id @@ -38,4 +40,16 @@ class User < ApplicationRecord 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 end diff --git a/app/services/cdn_stats_service.rb b/app/services/cdn_stats_service.rb new file mode 100644 index 0000000..18454e7 --- /dev/null +++ b/app/services/cdn_stats_service.rb @@ -0,0 +1,49 @@ +# 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) + { + total_files: user.total_files, + total_storage: user.total_storage_gb, + storage_formatted: "#{user.total_storage_gb} GB", + files_today: user.uploads.today.count, + files_this_week: user.uploads.this_week.count, + recent_uploads: user.uploads.includes(:blob).recent.limit(5) + } + end + + private + + def self.calculate_global_stats + total_files = Upload.count + total_storage_bytes = Upload.joins(:blob).sum('active_storage_blobs.byte_size') + total_storage_gb = (total_storage_bytes / 1.gigabyte.to_f).round(2) + total_users = User.joins(:uploads).distinct.count + + { + total_files: total_files, + total_storage_gb: total_storage_gb, + storage_formatted: "#{total_storage_gb} GB", + total_users: total_users, + files_today: Upload.today.count, + files_this_week: Upload.this_week.count + } + end +end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index cb84914..f9c1879 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -34,7 +34,7 @@ - <%= render(Components::HeaderBar.new) %> + <%= render(Components::HeaderBar.new) if signed_in? %> <%= yield %> diff --git a/app/views/static_pages/home.html.erb b/app/views/static_pages/home.html.erb index 18dc087..a4ad898 100644 --- a/app/views/static_pages/home.html.erb +++ b/app/views/static_pages/home.html.erb @@ -1,5 +1,5 @@ <% if signed_in? %> - + <%= render Components::StaticPages::Home.new(stats: @user_stats, user: current_user) %> <% else %> - <%= button_to "Sign in with Hack Club", "/auth/hack_club", method: :post, class: "btn btn-primary btn-large", data: { turbo: false } %> -<% end %> + <%= render Components::StaticPages::LoggedOut.new(stats: @global_stats) %> +<% end %> \ No newline at end of file diff --git a/config/environments/production.rb b/config/environments/production.rb index 68953ef..3fcbf55 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -21,8 +21,8 @@ Rails.application.configure do # Enable serving of images, stylesheets, and JavaScripts from an asset server. # config.asset_host = "http://assets.example.com" - # Store uploaded files on the local file system (see config/storage.yml for options). - config.active_storage.service = :local + # Store uploaded files on Amazon S3 or S3-compatible storage (see config/storage.yml for options). + config.active_storage.service = :amazon # Assume all access to the app is happening through a SSL-terminating reverse proxy. config.assume_ssl = true diff --git a/config/recurring.yml b/config/recurring.yml index b4207f9..bc107dc 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -13,3 +13,6 @@ production: clear_solid_queue_finished_jobs: command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" schedule: every hour at minute 12 + refresh_cdn_stats: + class: RefreshCDNStatsJob + schedule: every 5 minutes diff --git a/config/storage.yml b/config/storage.yml index 4942ab6..f4eaa9d 100644 --- a/config/storage.yml +++ b/config/storage.yml @@ -6,13 +6,15 @@ local: service: Disk root: <%= Rails.root.join("storage") %> -# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) -# amazon: -# service: S3 -# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> -# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> -# region: us-east-1 -# bucket: your_own_bucket-<%= Rails.env %> +# Cloudflare R2 (S3-compatible) +amazon: + service: S3 + access_key_id: <%= ENV['R2_ACCESS_KEY_ID'] %> + secret_access_key: <%= ENV['R2_SECRET_ACCESS_KEY'] %> + region: auto + bucket: <%= ENV['R2_BUCKET_NAME'] %> + endpoint: <%= ENV['R2_ENDPOINT'] %> # Format: https://.r2.cloudflarestorage.com + force_path_style: true # Remember not to checkin your GCS keyfile to a repository # google: diff --git a/db/migrate/20260129051530_create_active_storage_tables.active_storage.rb b/db/migrate/20260129051530_create_active_storage_tables.active_storage.rb new file mode 100644 index 0000000..6bd8bd0 --- /dev/null +++ b/db/migrate/20260129051530_create_active_storage_tables.active_storage.rb @@ -0,0 +1,57 @@ +# This migration comes from active_storage (originally 20170806125915) +class CreateActiveStorageTables < ActiveRecord::Migration[7.0] + def change + # Use Active Record's configured type for primary and foreign keys + primary_key_type, foreign_key_type = primary_and_foreign_key_types + + create_table :active_storage_blobs, id: primary_key_type do |t| + t.string :key, null: false + t.string :filename, null: false + t.string :content_type + t.text :metadata + t.string :service_name, null: false + t.bigint :byte_size, null: false + t.string :checksum + + if connection.supports_datetime_with_precision? + t.datetime :created_at, precision: 6, null: false + else + t.datetime :created_at, null: false + end + + t.index [ :key ], unique: true + end + + create_table :active_storage_attachments, id: primary_key_type do |t| + t.string :name, null: false + t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type + t.references :blob, null: false, type: foreign_key_type + + if connection.supports_datetime_with_precision? + t.datetime :created_at, precision: 6, null: false + else + t.datetime :created_at, null: false + end + + t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + + create_table :active_storage_variant_records, id: primary_key_type do |t| + t.belongs_to :blob, null: false, index: false, type: foreign_key_type + t.string :variation_digest, null: false + + t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + end + + private + def primary_and_foreign_key_types + config = Rails.configuration.generators + setting = config.options[config.orm][:primary_key_type] + primary_key_type = setting || :primary_key + foreign_key_type = setting || :bigint + [ primary_key_type, foreign_key_type ] + end +end diff --git a/db/migrate/20260129051531_create_uploads.rb b/db/migrate/20260129051531_create_uploads.rb new file mode 100644 index 0000000..5214f2c --- /dev/null +++ b/db/migrate/20260129051531_create_uploads.rb @@ -0,0 +1,20 @@ +class CreateUploads < ActiveRecord::Migration[8.0] + def change + create_table :uploads, id: :uuid do |t| + t.references :user, null: false, foreign_key: true, type: :bigint + t.references :blob, null: false, foreign_key: { to_table: :active_storage_blobs } + + # Upload source tracking + t.string :provenance, null: false # enum: slack, web, api, rescued + + # For rescued files from old hel1 bucket + t.string :original_url # Old CDN URL to fixup + + t.timestamps + + t.index [:user_id, :created_at] + t.index :created_at + t.index :provenance + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 6397479..5498b86 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,52 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_01_27_174404) do +ActiveRecord::Schema[8.0].define(version: 2026_01_29_051531) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" + create_table "active_storage_attachments", force: :cascade do |t| + t.string "name", null: false + t.string "record_type", null: false + t.bigint "record_id", null: false + t.bigint "blob_id", null: false + t.datetime "created_at", null: false + t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" + t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true + end + + create_table "active_storage_blobs", force: :cascade do |t| + t.string "key", null: false + t.string "filename", null: false + t.string "content_type" + t.text "metadata" + t.string "service_name", null: false + t.bigint "byte_size", null: false + t.string "checksum" + t.datetime "created_at", null: false + t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true + end + + create_table "active_storage_variant_records", force: :cascade do |t| + t.bigint "blob_id", null: false + t.string "variation_digest", null: false + t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true + end + + create_table "uploads", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "blob_id", null: false + t.string "provenance", null: false + t.string "original_url" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["blob_id"], name: "index_uploads_on_blob_id" + t.index ["created_at"], name: "index_uploads_on_created_at" + t.index ["provenance"], name: "index_uploads_on_provenance" + t.index ["user_id", "created_at"], name: "index_uploads_on_user_id_and_created_at" + t.index ["user_id"], name: "index_uploads_on_user_id" + end + create_table "users", force: :cascade do |t| t.string "hca_id" t.text "hca_access_token" @@ -26,4 +68,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_01_27_174404) do 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 + + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" + add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "uploads", "active_storage_blobs", column: "blob_id" + add_foreign_key "uploads", "users" end diff --git a/db/seeds.rb b/db/seeds.rb index 4fbd6ed..55ff71f 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -7,3 +7,41 @@ # ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| # MovieGenre.find_or_create_by!(name: genre_name) # end + +if Rails.env.development? + user = User.first || User.create!( + hca_id: 'dev_user', + email: 'dev@example.com', + name: 'Dev User' + ) + + provenances = [:web, :api, :slack, :rescued] + + 10.times do |i| + # Create dummy file content + content = "This is test file #{i} content. " * 100 + + # Create blob (ActiveStorage handles file storage to ./storage/ in dev) + blob = ActiveStorage::Blob.create_and_upload!( + io: StringIO.new(content), + filename: "test_file_#{i}.jpg", + content_type: 'image/jpeg' + ) + + # Create upload record + provenance = provenances.sample + Upload.create!( + user: user, + blob: blob, + provenance: provenance, + original_url: provenance == :rescued ? "https://hel1.cdn.hackclub.com/old_file_#{i}.jpg" : nil, + created_at: rand(30.days.ago..Time.current) + ) + end + + puts "Created #{Upload.count} sample uploads for #{user.name}" + puts "Provenance breakdown:" + Upload.group(:provenance).count.each do |prov, count| + puts " #{prov}: #{count}" + end +end diff --git a/package.json b/package.json index 00791f2..1548729 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,10 @@ "vite-plugin-ruby": "5.1.1" }, "dependencies": { + "@hotwired/turbo": "^8.0.22", "@primer/css": "^22.1.0", "@primer/primitives": "^11.3.2", - "@primer/view-components": "^0.49.0" + "@primer/view-components": "^0.49.0", + "@rails/ujs": "^7.1.3-4" } } diff --git a/vite.config.ts b/vite.config.ts index 19a2f1e..d70706f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,6 +10,14 @@ export default defineConfig({ minify: false }, + resolve: { + dedupe: ['@primer/view-components', '@github/catalyst'] + }, + + optimizeDeps: { + include: ['@primer/view-components'] + }, + server: { hmr: { overlay: true diff --git a/yarn.lock b/yarn.lock index 8ce1d56..0b6575c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -201,6 +201,11 @@ resolved "https://registry.yarnpkg.com/@github/tab-container-element/-/tab-container-element-3.4.0.tgz#0a167ed81c9f4a1c1c79f0d0a6a8c012c49a6147" integrity sha512-Yx70pO8A0p7Stnm9knKkUNX8i4bjuwDYZarRkM8JH0Z+ffhpe++oNAPbzGI9GEcGugRHvKuSC6p4YOdoHtTniQ== +"@hotwired/turbo@^8.0.22": + version "8.0.22" + resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-8.0.22.tgz#f6f8c0756f48ce83c31675998d55c37fdf1a6828" + integrity sha512-A7M8vBgsmZ8W55IOEhTN7o0++zaJTavyGa1DW1rt+/b4VTr8QUWC/zu8wxMmwqvczaOe1nI/MkMq4lm2NmiHPg== + "@lit-labs/ssr-dom-shim@^1.2.1": version "1.5.1" resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.5.1.tgz#3166900c0d481f03d6d4133686e0febf760d521d" @@ -362,6 +367,11 @@ "@primer/behaviors" "^1.3.4" "@primer/live-region-element" "^0.8.0" +"@rails/ujs@^7.1.3-4": + version "7.1.3-4" + resolved "https://registry.yarnpkg.com/@rails/ujs/-/ujs-7.1.3-4.tgz#1dddea99d5c042e8513973ea709b2cb7e840dc2d" + integrity sha512-z0ckI5jrAJfImcObjMT1RBz2IxH6I5q6ZTMFex6AfxSQKZuuL8JxAXvg2CvBuodGCxKvybFVolDyMHXlBLeYAA== + "@rollup/rollup-android-arm-eabi@4.57.0": version "4.57.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz#f762035679a6b168138c94c960fda0b0cdb00d98"