From b546f94b71b5d2f09e98206c80ac6fa248ad8c6d Mon Sep 17 00:00:00 2001 From: SeradedStripes Date: Thu, 16 Jul 2026 00:57:32 +0200 Subject: [PATCH 01/14] Add rubyzip to gemfile --- Gemfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Gemfile b/Gemfile index 7a8b81bcc..e5e85999f 100644 --- a/Gemfile +++ b/Gemfile @@ -118,6 +118,7 @@ gem "mission_control-jobs" gem "view_component" gem "phlex-rails" gem "aws-sdk-s3" +gem "rubyzip", "~> 2.3" gem "faraday" gem "faraday-retry" From b7033a898cdc500a74907443918bb1808e91b695 Mon Sep 17 00:00:00 2001 From: SeradedStripes Date: Thu, 16 Jul 2026 01:00:16 +0200 Subject: [PATCH 02/14] Update rubyzip to latest version --- Gemfile | 2 +- Gemfile.lock | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index e5e85999f..2cc15bb77 100644 --- a/Gemfile +++ b/Gemfile @@ -118,7 +118,7 @@ gem "mission_control-jobs" gem "view_component" gem "phlex-rails" gem "aws-sdk-s3" -gem "rubyzip", "~> 2.3" +gem "rubyzip", "~> 3.0" gem "faraday" gem "faraday-retry" diff --git a/Gemfile.lock b/Gemfile.lock index d34f2b12e..14341891b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -865,6 +865,7 @@ DEPENDENCIES rspec-rails rubocop rubocop-rails-omakase + rubyzip (~> 3.0) selenium-webdriver sentry-rails (~> 6.6) sentry-ruby (~> 6.6) From 8a92cb595808534b570fa039a0ccd8e92a0795f5 Mon Sep 17 00:00:00 2001 From: SeradedStripes Date: Thu, 16 Jul 2026 01:00:39 +0200 Subject: [PATCH 03/14] User data exports migration --- .../20260715225859_create_user_data_exports.rb | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 db/migrate/20260715225859_create_user_data_exports.rb diff --git a/db/migrate/20260715225859_create_user_data_exports.rb b/db/migrate/20260715225859_create_user_data_exports.rb new file mode 100644 index 000000000..bd87fac33 --- /dev/null +++ b/db/migrate/20260715225859_create_user_data_exports.rb @@ -0,0 +1,14 @@ +class CreateUserDataExports < ActiveRecord::Migration[8.1] + def change + create_table :user_data_exports do |t| + t.references :user, null: false, foreign_key: true + t.string :status, null: false, default: "pending" + t.text :error_message + t.string :zip_filename + + t.timestamps + end + + add_index :user_data_exports, [:user_id, :status] + end +end From 71b20f620140f5b63331d0e36e26ed012d0cb661 Mon Sep 17 00:00:00 2001 From: SeradedStripes Date: Thu, 16 Jul 2026 01:04:46 +0200 Subject: [PATCH 04/14] User::DataExport Model --- app/models/user/data_export.rb | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 app/models/user/data_export.rb diff --git a/app/models/user/data_export.rb b/app/models/user/data_export.rb new file mode 100644 index 000000000..e51129725 --- /dev/null +++ b/app/models/user/data_export.rb @@ -0,0 +1,29 @@ +class User::DataExport < ApplicationRecord + belongs_to :user + has_one_attached :zip_file + + validates :status, inclusion: { in: %w[pending processing completed failed] } + + scope :completed, -> { where(status: "completed") } + scope :pending_or_processing, -> { where(status: %w[pending processing]) } + + def download_available? + completed? && zip_file.attached? + end + + def completed? + status == "completed" + end + + def failed? + status == "failed" + end + + def processing? + status == "processing" + end + + def pending? + status == "pending" + end +end From 7d4fa234fe281c418ae76f911fbd79ddf717f1eb Mon Sep 17 00:00:00 2001 From: SeradedStripes Date: Thu, 16 Jul 2026 02:50:40 +0200 Subject: [PATCH 05/14] Data export job --- app/jobs/user/data_export_job.rb | 140 +++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 app/jobs/user/data_export_job.rb diff --git a/app/jobs/user/data_export_job.rb b/app/jobs/user/data_export_job.rb new file mode 100644 index 000000000..bc4c1b47f --- /dev/null +++ b/app/jobs/user/data_export_job.rb @@ -0,0 +1,140 @@ +require "zip" + +class User::DataExportJob < ApplicationJob + queue_as :background + + def perform(data_export_id) + @data_export = User::DataExport.find_by(id: data_export_id) + return unless @data_export + + @data_export.update!(status: "processing") + + user = @data_export.user + zip_filename = "stardance-export-#{user.display_name.parameterize}-#{Time.current.strftime("%Y%m%d%H%M%S")}.zip" + + temp_zip = Tempfile.new([ "stardance_export", ".zip" ]) + + begin + Zip::OutputStream.open(temp_zip.path) do |zip| + write_profile(zip, user) + write_projects(zip, user) + write_readme(zip, user) + end + + @data_export.update!(status: "completed", zip_filename: zip_filename) + @data_export.zip_file.attach( + io: File.open(temp_zip.path), + filename: zip_filename, + content_type: "application/zip" + ) + rescue StandardError => e + @data_export.update!(status: "failed", error_message: "#{e.class}: #{e.message}") + raise e + ensure + temp_zip.close + temp_zip.unlink + end + end + + private + + def write_profile(zip, user) + profile_data = { + id: user.id, + display_name: user.display_name, + email: user.email, + first_name: user.first_name, + last_name: user.last_name, + bio: user.bio, + created_at: user.created_at, + updated_at: user.updated_at + } + + zip.put_next_entry("profile.json") + zip.write(JSON.pretty_generate(profile_data)) + end + + def write_projects(zip, user) + user.projects.includes(:devlogs).find_each do |project| + safe_title = project.title.parameterize.presence || "project-#{project.id}" + project_dir = "projects/#{safe_title}" + + project_data = { + id: project.id, + title: project.title, + description: project.description, + demo_url: project.demo_url, + repo_url: project.repo_url, + readme_url: project.readme_url, + ship_status: project.ship_status, + shipped_at: project.shipped_at, + created_at: project.created_at, + updated_at: project.updated_at + } + + zip.put_next_entry("#{project_dir}/project.json") + zip.write(JSON.pretty_generate(project_data)) + + download_attachment(zip, project.banner, "#{project_dir}/banner") if project.banner.attached? + download_attachment(zip, project.demo_video, "#{project_dir}/demo-video") if project.demo_video.attached? + + write_devlogs(zip, project, project_dir) + end + end + + def write_devlogs(zip, project, project_dir) + project.devlogs.includes(:post).find_each do |devlog| + devlog_dir = "#{project_dir}/devlogs" + + zip.put_next_entry("#{devlog_dir}/devlog-#{devlog.id}.md") + zip.write(devlog.body.to_s) + + if devlog.attachments.attached? + devlog.attachments.each_with_index do |attachment, index| + ext = File.extname(attachment.filename.to_s).presence || ".bin" + download_attachment(zip, attachment, "#{devlog_dir}/attachments/#{index + 1}#{ext}") + end + end + end + end + + def download_attachment(zip, attachment, entry_name) + blob = attachment.is_a?(ActiveStorage::Attached) ? attachment.blob : attachment + return unless blob + + zip.put_next_entry(entry_name) + zip.write(blob.download) + rescue StandardError => e + Rails.logger.warn("DataExport: failed to download attachment #{blob&.filename}: #{e.message}") + end + + def write_readme(zip, user) + project_count = user.projects.count + devlog_count = user.projects.joins(:devlog_posts).count + + readme = <<~README + # Stardance Data Export + + **User:** #{user.display_name} + **Exported:** #{Time.current.strftime("%B %d, %Y at %H:%M UTC")} + + ## Contents + + - `profile.json` - Your profile data + - `projects/` - Your projects, each containing: + - `project.json` - Project metadata + - `banner` - Project banner image (if uploaded) + - `demo-video` - Demo video (if uploaded) + - `devlogs/` - Development logs as Markdown files + - `attachments/` - Images and files from each devlog + + ## Stats + + - **Projects:** #{project_count} + - **Devlogs:** #{devlog_count} + README + + zip.put_next_entry("README.md") + zip.write(readme) + end +end From f871bded38450e273a1c82eade720aa742a97435 Mon Sep 17 00:00:00 2001 From: SeradedStripes Date: Thu, 16 Jul 2026 04:33:05 +0200 Subject: [PATCH 06/14] DataExportsController --- app/controllers/my/data_exports_controller.rb | 46 +++++++++++++++++++ app/models/user.rb | 1 + 2 files changed, 47 insertions(+) create mode 100644 app/controllers/my/data_exports_controller.rb diff --git a/app/controllers/my/data_exports_controller.rb b/app/controllers/my/data_exports_controller.rb new file mode 100644 index 000000000..f4a184854 --- /dev/null +++ b/app/controllers/my/data_exports_controller.rb @@ -0,0 +1,46 @@ +class My::DataExportsController < ApplicationController + before_action :authenticate_user! + + def index + authorize :my, :show_data_exports? + + @body_class = "app-layout-page" + @data_exports = current_user.data_exports.order(created_at: :desc).limit(20) + end + + def create + authorize :my, :create_data_export? + + active_export = current_user.data_exports.pending_or_processing.last + if active_export + redirect_to my_data_exports_path, notice: "An export is already in progress. Please wait for it to complete." + return + end + + data_export = current_user.data_exports.create!(status: "pending") + User::DataExportJob.perform_later(data_export.id) + + redirect_to my_data_exports_path, notice: "Your data export has been queued. You'll be able to download it once it's ready." + end + + def show + data_export = current_user.data_exports.find(params[:id]) + authorize :my, :download_data_export? + + unless data_export.download_available? + redirect_to my_data_exports_path, alert: "This export is not ready for download." + return + end + + redirect_to rails_blob_path(data_export.zip_file, disposition: "attachment") + end + + private + + def authenticate_user! + return if current_user.present? + + store_return_to + redirect_to root_path, alert: "Please sign in to continue." + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 4a6e4c6ee..1b50aa2b4 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -103,6 +103,7 @@ class User < ApplicationRecord has_many :project_follows, dependent: :destroy has_many :followed_projects, through: :project_follows, source: :project has_one :preference, class_name: "User::Preference", dependent: :destroy + has_many :data_exports, class_name: "User::DataExport", dependent: :destroy has_many :follows_as_follower, class_name: "Follow", foreign_key: :follower_id, dependent: :destroy, inverse_of: :follower has_many :follows_as_followed, class_name: "Follow", foreign_key: :followed_id, dependent: :destroy, inverse_of: :followed From 555d25c83fd0cad1e584e3150e60b7101589ab06 Mon Sep 17 00:00:00 2001 From: SeradedStripes Date: Thu, 16 Jul 2026 04:34:55 +0200 Subject: [PATCH 07/14] Update MyPolicy with data export authorization --- app/policies/my_policy.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/policies/my_policy.rb b/app/policies/my_policy.rb index 5b1a1697e..81ee9dc38 100644 --- a/app/policies/my_policy.rb +++ b/app/policies/my_policy.rb @@ -30,4 +30,16 @@ def show_notification_settings? def update_notification_settings? signed_in_any? end + + def show_data_exports? + signed_in_any? + end + + def create_data_export? + signed_in_any? + end + + def download_data_export? + signed_in_any? + end end From eb8ca57ff670d66629e0a9bd7ddddfa7d0df3a0b Mon Sep 17 00:00:00 2001 From: SeradedStripes Date: Thu, 16 Jul 2026 04:37:02 +0200 Subject: [PATCH 08/14] Added routes for data exports --- config/routes.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/config/routes.rb b/config/routes.rb index ce51a4966..40515fd98 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -578,6 +578,7 @@ end end resource :notification_settings, only: [ :show, :update ], controller: "notification_settings" + resources :data_exports, only: [ :index, :create, :show ] end get "my/achievements", to: "achievements#index", as: :my_achievements From cf67a15b09f4f8299c9b698ec979468de1769e4e Mon Sep 17 00:00:00 2001 From: SeradedStripes Date: Thu, 16 Jul 2026 04:48:35 +0200 Subject: [PATCH 09/14] Create export page view --- app/views/my/data_exports/index.html.erb | 56 ++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 app/views/my/data_exports/index.html.erb diff --git a/app/views/my/data_exports/index.html.erb b/app/views/my/data_exports/index.html.erb new file mode 100644 index 000000000..abb4a6e3b --- /dev/null +++ b/app/views/my/data_exports/index.html.erb @@ -0,0 +1,56 @@ +<% content_for :title, "Export My Data" %> +<% @active_nav_slug = "data_exports" %> + +
+
+
+

Export My Data

+

+ Download a ZIP file containing all your projects, devlogs, images, and profile data. +

+
+ +
+ <%= button_to "Request Export", my_data_exports_path, + method: :post, + class: "action-btn action-btn--large action-btn--primary" %> +
+ + <% if @data_exports.any? %> +
+ <% @data_exports.each do |export| %> +
+
+ <%= export.status.humanize %> + + <%= export.created_at.strftime("%b %d, %Y at %H:%M") %> + + <% if export.failed? && export.error_message.present? %> + <%= export.error_message %> + <% end %> +
+
+ <% if export.download_available? %> + <%= link_to "Download", my_data_export_path(export), + class: "action-btn action-btn--small action-btn--secondary" %> + <% elsif export.processing? %> + Generating… + <% elsif export.pending? %> + Queued… + <% end %> +
+
+ <% end %> +
+ <% else %> +
+

No exports yet

+

+ Click "Request Export" above to start generating your data archive. +

+
+ <% end %> +
+ + <%= render DiscoverRailComponent.new %> +
From 594f0b87577759bb6fe9c966a9d4af51f2b5cadf Mon Sep 17 00:00:00 2001 From: SeradedStripes Date: Thu, 16 Jul 2026 04:54:32 +0200 Subject: [PATCH 10/14] Create _data_exports.scss --- .../stylesheets/pages/_data_exports.scss | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 app/assets/stylesheets/pages/_data_exports.scss diff --git a/app/assets/stylesheets/pages/_data_exports.scss b/app/assets/stylesheets/pages/_data_exports.scss new file mode 100644 index 000000000..4f0625ba0 --- /dev/null +++ b/app/assets/stylesheets/pages/_data_exports.scss @@ -0,0 +1,92 @@ +.data-exports-page { + max-width: 720px; + width: 100%; + margin: 0 auto; + color: var(--color-space-text); + + &__header { + margin-bottom: var(--space-m); + } + + &__title { + font-family: var(--font-family-subtitle); + font-size: var(--font-size-xxl); + margin: 0 0 var(--space-xxs); + } + + &__description { + margin: 0; + color: var(--color-space-text-muted); + font-size: var(--font-size-s); + } + + &__actions { + margin-bottom: var(--space-l); + } + + &__list { + display: flex; + flex-direction: column; + gap: var(--space-xs); + } + + &__empty { + padding: var(--space-xl) var(--space-l); + text-align: center; + margin-top: var(--space-m); + } + + &__empty-title { + font-family: var(--font-family-subtitle); + font-size: var(--font-size-l); + margin: 0 0 var(--space-xxs); + color: var(--color-space-text); + } + + &__empty-hint { + margin: 0; + color: var(--color-space-text-muted); + font-size: var(--font-size-s); + } +} + +.data-export-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-s); + padding: var(--space-s) var(--space-m); + color: var(--color-space-text); + background: rgba(5, 4, 24, 0.35); + border: 2px solid var(--color-space-border); + border-radius: var(--profile-radius, var(--border-radius)); + + &__info { + display: flex; + align-items: center; + gap: var(--space-s); + min-width: 0; + } + + &__date { + opacity: 0.5; + font-weight: 400; + font-family: var(--font-family-text); + font-size: var(--font-size-s); + } + + &__error { + color: var(--color-brand-salmon); + font-size: var(--font-size-xs); + } + + &__actions { + flex: 0 0 auto; + } + + &__processing { + opacity: 0.5; + font-size: var(--font-size-s); + font-style: italic; + } +} From bf83bc888768738f80e299ba9de3144011f9c4b6 Mon Sep 17 00:00:00 2001 From: SeradedStripes Date: Thu, 16 Jul 2026 04:56:08 +0200 Subject: [PATCH 11/14] Wire styles in application.scss --- app/assets/stylesheets/application.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 97b83118c..a0d4b2b9b 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -98,5 +98,6 @@ @use "pages/raffle"; @use "pages/notifications" as notifications_page; @use "pages/notification_settings" as notification_settings_page; +@use "pages/data_exports" as data_exports_page; @use "pages/rng"; @use "landing/landing_footer"; From c7cafed126d97dae07cf3c7076c1766fd280a058 Mon Sep 17 00:00:00 2001 From: SeradedStripes Date: Thu, 16 Jul 2026 04:58:26 +0200 Subject: [PATCH 12/14] RubuCop offense auto-corrected --- db/migrate/20260715225859_create_user_data_exports.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/migrate/20260715225859_create_user_data_exports.rb b/db/migrate/20260715225859_create_user_data_exports.rb index bd87fac33..9822e5e4b 100644 --- a/db/migrate/20260715225859_create_user_data_exports.rb +++ b/db/migrate/20260715225859_create_user_data_exports.rb @@ -9,6 +9,6 @@ def change t.timestamps end - add_index :user_data_exports, [:user_id, :status] + add_index :user_data_exports, [ :user_id, :status ] end end From e6b360106b8d3e9242efc812b4b2c5df5a8cf13c Mon Sep 17 00:00:00 2001 From: SeradedStripes Date: Thu, 16 Jul 2026 05:04:06 +0200 Subject: [PATCH 13/14] schema updates --- app/models/user/data_export.rb | 21 +++++++++++++++++++++ db/schema.rb | 14 +++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/app/models/user/data_export.rb b/app/models/user/data_export.rb index e51129725..b652604cb 100644 --- a/app/models/user/data_export.rb +++ b/app/models/user/data_export.rb @@ -1,3 +1,24 @@ +# == Schema Information +# +# Table name: user_data_exports +# +# id :bigint not null, primary key +# error_message :text +# status :string default("pending"), not null +# zip_filename :string +# created_at :datetime not null +# updated_at :datetime not null +# user_id :bigint not null +# +# Indexes +# +# index_user_data_exports_on_user_id (user_id) +# index_user_data_exports_on_user_id_and_status (user_id,status) +# +# Foreign Keys +# +# fk_rails_... (user_id => users.id) +# class User::DataExport < ApplicationRecord belongs_to :user has_one_attached :zip_file diff --git a/db/schema.rb b/db/schema.rb index 69cf1f2b9..fab6c5152 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_07_14_143837) do +ActiveRecord::Schema[8.1].define(version: 2026_07_15_225859) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "vector" @@ -1325,6 +1325,17 @@ t.index ["user_id"], name: "index_user_achievements_on_user_id" end + create_table "user_data_exports", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "error_message" + t.string "status", default: "pending", null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.string "zip_filename" + t.index ["user_id", "status"], name: "index_user_data_exports_on_user_id_and_status" + t.index ["user_id"], name: "index_user_data_exports_on_user_id" + end + create_table "user_hackatime_projects", force: :cascade do |t| t.datetime "created_at", null: false t.string "name", null: false @@ -1672,6 +1683,7 @@ add_foreign_key "show_and_tell_payout_records", "users", column: "payout_given_by_id" add_foreign_key "streak_activities", "users" add_foreign_key "user_achievements", "users" + add_foreign_key "user_data_exports", "users" add_foreign_key "user_hackatime_projects", "projects" add_foreign_key "user_hackatime_projects", "users" add_foreign_key "user_identities", "users" From 3e548226a357cf864fb33e20271a5aa34c4fdfee Mon Sep 17 00:00:00 2001 From: SeradedStripes Date: Thu, 16 Jul 2026 05:25:17 +0200 Subject: [PATCH 14/14] Add export button to settings menu with proper styling --- app/assets/stylesheets/components/_settings-form.scss | 8 ++++++++ app/components/sidebar_component.html.erb | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/app/assets/stylesheets/components/_settings-form.scss b/app/assets/stylesheets/components/_settings-form.scss index cd5a32445..966549933 100644 --- a/app/assets/stylesheets/components/_settings-form.scss +++ b/app/assets/stylesheets/components/_settings-form.scss @@ -138,6 +138,7 @@ border-radius: 999px; padding: var(--space-xxs) var(--space-m); cursor: pointer; + text-decoration: none; transition: border-color 160ms ease, background-color 160ms ease; @@ -146,4 +147,11 @@ border-color: var(--color-border-input-focus); } } + + &__export-btn { + display: block; + width: 100%; + text-align: center; + text-decoration: none; + } } diff --git a/app/components/sidebar_component.html.erb b/app/components/sidebar_component.html.erb index 059cd7aae..b0ce09be9 100644 --- a/app/components/sidebar_component.html.erb +++ b/app/components/sidebar_component.html.erb @@ -163,6 +163,12 @@ Links your coding time from Hackatime to your projects +
+ + <%= link_to "Export Data", my_data_exports_path, class: "settings-form__hackatime-btn settings-form__export-btn" %> + Download a ZIP of your projects, devlogs, and images +
+