Merge branch 'main' into timeline-page

This commit is contained in:
Zach Latta
2025-05-14 14:34:56 -04:00
committed by GitHub
35 changed files with 640 additions and 105 deletions

View File

@@ -56,4 +56,6 @@ GITHUB_CLIENT_SECRET=your_github_client_secret_here
SKYLIGHT_AUTHENTICATION=replace_me
IPINFO_API_KEY=replace_me
IPINFO_API_KEY=replace_me
MAIL_HACKCLUB_TOKEN=replace_me

View File

@@ -1 +1 @@
ruby-3.4.1
ruby-3.4.3

View File

@@ -8,7 +8,7 @@
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=3.4.1
ARG RUBY_VERSION=3.4.3
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
# Rails app lives here
@@ -37,7 +37,7 @@ FROM base AS build
# Install packages needed to build gems
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential git pkg-config libpq-dev && \
apt-get install --no-install-recommends -y build-essential git pkg-config libpq-dev libyaml-dev && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Install application gems

View File

@@ -1,4 +1,4 @@
FROM ruby:3.4.1
FROM ruby:3.4.3
# Install system dependencies
RUN apt-get update -qq && \
@@ -6,6 +6,7 @@ RUN apt-get update -qq && \
build-essential \
git \
libpq-dev \
libyaml-dev \
postgresql-client \
libvips \
pkg-config \

View File

@@ -102,7 +102,7 @@ GEM
device_detector (>= 1)
safely_block (>= 0.4)
ast (2.4.2)
avo (3.20.0)
avo (3.20.1)
actionview (>= 6.1)
active_link_to
activerecord (>= 6.1)
@@ -146,7 +146,7 @@ GEM
chartkick (5.1.5)
childprocess (5.1.0)
logger (~> 1.5)
concurrent-ruby (1.3.5)
concurrent-ruby (1.3.4)
connection_pool (2.5.3)
countries (7.1.1)
unaccent (~> 0.3)
@@ -268,7 +268,7 @@ GEM
ffi-compiler (~> 1.0)
rake (~> 13.0)
logger (1.7.0)
loofah (2.24.0)
loofah (2.24.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@@ -343,7 +343,7 @@ GEM
activesupport (>= 7.0.0)
rack
railties (>= 7.0.0)
psych (5.2.4)
psych (5.2.6)
date
stringio
public_suffix (6.0.2)
@@ -354,7 +354,7 @@ GEM
railties (>= 4.2)
raabro (1.4.0)
racc (1.8.1)
rack (3.1.13)
rack (3.1.14)
rack-cors (2.0.2)
rack (>= 2.0.0)
rack-mini-profiler (3.3.1)
@@ -461,7 +461,7 @@ GEM
gli
hashie
logger
solid_cable (3.0.7)
solid_cable (3.0.8)
actioncable (>= 7.2)
activejob (>= 7.2)
activerecord (>= 7.2)
@@ -508,9 +508,9 @@ GEM
uniform_notifier (1.17.0)
uri (1.0.3)
useragent (0.16.11)
view_component (3.21.0)
view_component (3.22.0)
activesupport (>= 5.2.0, < 8.1)
concurrent-ruby (~> 1.0)
concurrent-ruby (= 1.3.4)
method_source (~> 1.0)
web-console (4.2.1)
actionview (>= 6.0.0)
@@ -587,4 +587,4 @@ DEPENDENCIES
web-console
BUNDLED WITH
2.6.2
2.6.9

View File

@@ -151,4 +151,19 @@ h3+.mini-leaderboard {
.period-toggle-btn:hover:not(.active) {
background-color: rgba(255, 255, 255, 0.1);
}
}
.mini-leaderboard.loading .leaderboard-entry {
opacity: 0.7;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% { opacity: 0.7; }
50% { opacity: 0.4; }
100% { opacity: 0.7; }
}
turbo-frame#mini_leaderboard[aria-busy="true"]::before {
display: none !important;
}

View File

@@ -1,6 +1,7 @@
class Api::Hackatime::V1::HackatimeController < ApplicationController
before_action :set_user, except: [ :index ]
skip_before_action :verify_authenticity_token
before_action :set_raw_heartbeat_upload, only: [ :push_heartbeats ]
def index
redirect_to root_path
@@ -28,7 +29,7 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController
# {
# ...heartbeat_data
# }
heartbeat_array = [ heartbeat_params ]
heartbeat_array = Array(heartbeat_params)
new_heartbeat = handle_heartbeat(heartbeat_array)&.first&.first
render json: new_heartbeat, status: :accepted
end
@@ -51,6 +52,25 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController
private
def set_raw_heartbeat_upload
@raw_heartbeat_upload = RawHeartbeatUpload.create!(
request_headers: headers_to_json,
request_body: body_to_json
)
end
def headers_to_json
request.headers
.env
.select { |key| key.to_s.starts_with?("HTTP_") }
.map { |key, value| [ key.sub(/^HTTP_/, ""), value ] }
.to_h.to_json
end
def body_to_json
params.to_unsafe_h["_json"] || {}
end
def handle_heartbeat(heartbeat_array)
results = []
heartbeat_array.each do |heartbeat|
@@ -68,9 +88,14 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController
source_type: source_type,
ip_address: request.remote_ip,
editor: parsed_ua[:editor],
operating_system: parsed_ua[:os]
operating_system: parsed_ua[:os],
machine: request.headers["X-Machine-Name"]
})
new_heartbeat = Heartbeat.find_or_create_by(attrs)
if @raw_heartbeat_upload.present? && new_heartbeat.persisted?
new_heartbeat.raw_heartbeat_upload ||= @raw_heartbeat_upload
new_heartbeat.save! if new_heartbeat.changed?
end
queue_project_mapping(heartbeat[:project])
results << [ new_heartbeat.attributes, 201 ]
rescue => e

View File

@@ -35,7 +35,7 @@ module Api
# Format for API response using ISO8601 timestamps and returning extra fields as {} if not provided
summary = {
user_id: user.slack_uid,
user_id: params[:user],
from: Time.parse(wakatime_summary[:start]).iso8601,
to: Time.parse(wakatime_summary[:end]).iso8601,
projects: wakatime_summary[:projects] ? wakatime_summary[:projects].map { |item| { key: item[:name].presence || "Other", total: item[:total_seconds] } } : [],

View File

@@ -5,6 +5,7 @@ module My
# Generate OTC if it doesn't exist
if params[:from_fillout]
sleep 1 # unfortunate hack to make sure the job runs after airtable gets the data
FetchMailingAddressJob.perform_now(@user.id)
else
@user.update_column(:mailing_address_otc, SecureRandom.hex(8))

View File

@@ -0,0 +1,9 @@
module My
class MailroomController < ApplicationController
def index
@user = current_user
@physical_mails = @user.physical_mails.order(created_at: :desc)
@has_mailing_address = @user.mailing_address.present?
end
end
end

View File

@@ -5,16 +5,6 @@ class StaticPagesController < ApplicationController
]
def index
@leaderboard = Leaderboard.where.associated(:entries)
.where(start_date: Date.current)
.where(deleted_at: nil)
.where(period_type: :daily)
.distinct
.first
# Get active projects for the mini leaderboard
@active_projects = Cache::ActiveProjectsJob.perform_now
if current_user
flavor_texts = FlavorText.motto + FlavorText.conditional_mottos(current_user)
flavor_texts += FlavorText.rare_motto if Random.rand(10) < 1
@@ -80,6 +70,22 @@ class StaticPagesController < ApplicationController
end
end
def mini_leaderboard
@leaderboard = Leaderboard.where.associated(:entries)
.where(start_date: Date.current)
.where(deleted_at: nil)
.where(period_type: :daily)
.distinct
.first
@active_projects = Cache::ActiveProjectsJob.perform_now
render partial: "leaderboards/mini_leaderboard", locals: {
leaderboard: @leaderboard,
current_user: current_user
}
end
def project_durations
return unless current_user

View File

@@ -24,7 +24,7 @@ class UsersController < ApplicationController
if @user.uses_slack_status?
@user.update_slack_status
end
redirect_to is_own_settings? ? my_settings_path : user_settings_path(@user),
redirect_to is_own_settings? ? my_settings_path : settings_user_path(@user),
notice: "Settings updated successfully"
else
flash[:error] = "Failed to update settings"
@@ -35,7 +35,7 @@ class UsersController < ApplicationController
def migrate_heartbeats
MigrateUserFromHackatimeJob.perform_later(@user.id)
redirect_to is_own_settings? ? my_settings_path : user_settings_path(@user),
redirect_to is_own_settings? ? my_settings_path : settings_user_path(@user),
notice: "Heartbeats & api keys migration started"
end
@@ -96,7 +96,7 @@ class UsersController < ApplicationController
end
def is_own_settings?
@is_own_settings ||= !params["id"].present?
@is_own_settings ||= params["id"] == "my" || params["id"]&.blank?
end
def user_params

View File

@@ -0,0 +1,39 @@
class WakatimeMirrorsController < ApplicationController
before_action :set_user
before_action :require_current_user
before_action :set_mirror, only: [ :destroy ]
def create
@mirror = @user.wakatime_mirrors.build(mirror_params)
if @mirror.save
redirect_to my_settings_path, notice: "WakaTime mirror added successfully"
else
redirect_to my_settings_path, alert: "Failed to add WakaTime mirror: #{@mirror.errors.full_messages.join(', ')}"
end
end
def destroy
@mirror.destroy
redirect_to my_settings_path, notice: "WakaTime mirror removed successfully"
end
private
def set_user
@user = User.find(params[:user_id])
end
def set_mirror
@mirror = @user.wakatime_mirrors.find(params[:id])
end
def mirror_params
params.require(:wakatime_mirror).permit(:endpoint_url, :encrypted_api_key)
end
def require_current_user
unless @user == current_user
redirect_to root_path, alert: "You are not authorized to access this page"
end
end
end

View File

@@ -0,0 +1,38 @@
class CheckStreakPhysicalMailJob < ApplicationJob
queue_as :literally_whenever
include GoodJob::ActiveJobExtensions::Concurrency
good_job_control_concurrency_with(
total_limit: 1,
key: -> { "check_streak_physical_mail_job" },
drop: true
)
def perform
streaks = Heartbeat.daily_streaks_for_users(users_with_recent_heartbeats)
over_7_day_streaks = streaks.select { |_, streak| streak > 7 }.keys
over_7_day_streaks.each do |user_id|
next if PhysicalMail.going_out.exists?(user_id: user_id, mission_type: :first_time_7_streak)
user = User.find(user_id)
# Create the physical mail record
PhysicalMail.create!(
user: user,
mission_type: :first_time_7_streak,
status: :pending
)
end
end
private
def users_with_recent_heartbeats
Heartbeat.where(time: 1.hour.ago..Time.current)
.distinct
.pluck(:user_id)
end
end

View File

@@ -28,7 +28,14 @@ class OneTime::TransferUserDataJob < ApplicationJob
end
def transfer_api_keys
ApiKey.where(user_id: @source_user_id).update_all(user_id: @target_user_id)
ApiKey.where(user_id: @source_user_id).find_each do |api_key|
# If target user already has an API key with this name, append a suffix
if target_user.api_keys.exists?(name: api_key.name)
api_key.name = "#{api_key.name} (transferred)"
end
api_key.user_id = @target_user_id
api_key.save!
end
end
def transfer_heartbeats

View File

@@ -30,6 +30,9 @@ class SailorsLogPollForChangesJob < ApplicationJob
private
def update_sailors_log(sailors_log)
# Skip if there's an active migration job for this user
return [] if sailors_log.user.in_progress_migration_jobs?
project_updates = []
project_durations = Heartbeat.where(user_id: sailors_log.user.id)
.group(:project).duration_seconds

View File

@@ -0,0 +1,7 @@
class WakatimeMirrorSyncJob < ApplicationJob
queue_as :default
def perform(mirror)
mirror.sync_heartbeats
end
end

View File

@@ -87,9 +87,13 @@ class Heartbeat < ApplicationRecord
self.inheritance_column = nil
belongs_to :user
belongs_to :raw_heartbeat_upload, optional: true
has_many :wakatime_mirrors, dependent: :destroy
validates :time, presence: true
# after_create :mirror_to_wakatime
def self.recent_count
Cache::HeartbeatCountsJob.perform_now[:recent_count]
end
@@ -128,4 +132,8 @@ class Heartbeat < ApplicationRecord
self.fields_hash = self.class.generate_fields_hash(self.attributes)
end
end
# def mirror_to_wakatime
# WakatimeMirror.mirror_heartbeat(self)
# end
end

View File

@@ -0,0 +1,73 @@
class PhysicalMail < ApplicationRecord
belongs_to :user
scope :going_out, -> { where(status: :pending).or(where(status: :sent)) }
enum :status, {
pending: 0,
sent: 1,
failed: 2
}
enum :mission_type, {
admin_mail: 0,
first_time_7_streak: 1
}
def link_to_theseus
return nil if theseus_id.nil?
"https://hack.club/#{theseus_id}"
end
def humanized_mission_type
return "Your first 7-day streak" if first_time_7_streak?
mission_type.titleize
end
def deliver!
slug = "hackatime-#{mission_type.to_s.gsub("_", "-")}"
flavors = FlavorText.compliment
flavors.concat(FlavorText.rare_compliment) if rand(10) == 0
# authorization: Bearer <token>
response = HTTP.auth("Bearer #{ENV["MAIL_HACKCLUB_TOKEN"]}").post("https://mail.hackclub.com/api/v1/letter_queues/#{slug}", json: {
recipient_email: user.email_addresses.first.email,
address: {
first_name: user.mailing_address.first_name,
last_name: user.mailing_address.last_name,
line_1: user.mailing_address.line_1,
line_2: user.mailing_address.line_2,
city: user.mailing_address.city,
state: user.mailing_address.state,
postal_code: user.mailing_address.zip_code,
country: user.mailing_address.country
},
rubber_stamps: flavors.sample,
idempotency_key: "physical_mail_#{id}",
metadata: {
attributes: attributes
}
})
if response.status.success?
data = JSON.parse(response.body.to_s)
puts "Successfully delivered physical mail: #{data["id"]}"
update(status: :sent, theseus_id: data["id"])
else
update(status: :failed)
raise "Failed to deliver physical mail: #{response.body}"
end
rescue => e
update(status: :failed)
raise e
end
private
def user_address
user.mailing_address
end
end

View File

@@ -0,0 +1,6 @@
class RawHeartbeatUpload < ApplicationRecord
has_many :heartbeats
validates :request_headers, presence: true
validates :request_body, presence: true
end

View File

@@ -19,6 +19,7 @@ class User < ApplicationRecord
has_many :sign_in_tokens, dependent: :destroy
has_many :project_repo_mappings
has_one :mailing_address, dependent: :destroy
has_many :physical_mails
has_many :hackatime_heartbeats,
foreign_key: :user_id,
@@ -37,10 +38,19 @@ class User < ApplicationRecord
primary_key: :slack_uid,
class_name: "SailorsLog"
has_many :wakatime_mirrors, dependent: :destroy
def streak_days
@streak_days ||= heartbeats.daily_streaks_for_users([ id ]).values.first
end
if Rails.env.development?
def self.slow_find_by_email(email)
# This is an n+1 query, but provided for developer convenience
EmailAddress.find_by(email: email)&.user
end
end
def streak_days_formatted
if streak_days > 7
"7+"
@@ -67,6 +77,13 @@ class User < ApplicationRecord
).order(created_at: :desc).limit(10).all
end
def in_progress_migration_jobs?
GoodJob::Job.where(job_class: "MigrateUserFromHackatimeJob")
.where("serialized_params->>'arguments' = ?", [ id ].to_json)
.where(finished_at: nil)
.exists?
end
def set_neighborhood_channel
return unless slack_uid.present?

View File

@@ -0,0 +1,61 @@
class WakatimeMirror < ApplicationRecord
belongs_to :user
has_many :heartbeats, through: :user
encrypts :encrypted_api_key, deterministic: false
validates :endpoint_url, presence: true
validates :encrypted_api_key, presence: true
validates :endpoint_url, uniqueness: { scope: :user_id }
validate :endpoint_url_not_hackatime
after_create :schedule_initial_sync
def unsynced_heartbeats
# Get heartbeats since last sync, or all heartbeats if never synced
user.heartbeats.where("created_at > ?", last_synced_at || Time.at(0))
end
def sync_heartbeats
return unless encrypted_api_key.present?
# Get the next batch of heartbeats to sync (max 25 per WakaTime API limit)
batch = unsynced_heartbeats.limit(25).to_a
return if batch.empty?
# Send them all in a single request using the bulk endpoint
begin
body = batch.map { |h| h.attributes.slice(*Heartbeat.indexed_attributes) }
response = HTTP.headers(
"Authorization" => "Basic #{Base64.strict_encode64(encrypted_api_key + ':')}",
"Content-Type" => "application/json"
).post(
"#{endpoint_url}/users/current/heartbeats.bulk",
json: body
)
if response.status.success?
update_column(:last_synced_at, Time.current)
puts "Successfully synced #{batch.size} heartbeats: #{response.body}"
# queue another sync job
WakatimeMirrorSyncJob.perform_later(self)
else
Rails.logger.error("Failed to sync heartbeats to #{endpoint_url}: #{response.body}")
end
rescue => e
Rails.logger.error("Error syncing heartbeats to #{endpoint_url}: #{e.message}")
end
end
private
def endpoint_url_not_hackatime
if endpoint_url.present? && endpoint_url.include?("hackatime.hackclub.com")
errors.add(:endpoint_url, "cannot be hackatime.hackclub.com")
end
end
def schedule_initial_sync
WakatimeMirrorSyncJob.perform_later(self)
end
end

View File

@@ -1,78 +1,82 @@
<%
entries = leaderboard.entries.order(total_seconds: :desc)
if current_user
user_rank = entries.find_index { |entry| entry.user_id == current_user.id }
if user_rank && user_rank >= 3
# Show top 2 entries and immediate competition
top_entries = entries[0..1]
competition_entries = entries[[user_rank - 1, 2].max..[user_rank + 1, entries.size - 1].min]
mini_leaderboard_entries = top_entries + competition_entries
show_top_entries = false
else
# Show top 3 entries (either user is in top 3 or not on leaderboard)
mini_leaderboard_entries = entries.first(3)
show_top_entries = true
end
else
# Not logged in, show top 3
mini_leaderboard_entries = entries.first(3)
show_top_entries = true
end
%>
<%= turbo_frame_tag "mini_leaderboard" do %>
<% if leaderboard.present? %>
<%
entries = leaderboard.entries.order(total_seconds: :desc)
if current_user
user_rank = entries.find_index { |entry| entry.user_id == current_user.id }
if user_rank && user_rank >= 3
# Show top 2 entries and immediate competition
top_entries = entries[0..1]
competition_entries = entries[[user_rank - 1, 2].max..[user_rank + 1, entries.size - 1].min]
mini_leaderboard_entries = top_entries + competition_entries
show_top_entries = false
else
# Show top 3 entries (either user is in top 3 or not on leaderboard)
mini_leaderboard_entries = entries.first(3)
show_top_entries = true
end
else
# Not logged in, show top 3
mini_leaderboard_entries = entries.first(3)
show_top_entries = true
end
%>
<% if mini_leaderboard_entries&.any? %>
<div class="mini-leaderboard">
<p class="super">
This leaderboard is in <%= Leaderboard::GLOBAL_TIMEZONE %>.
<% if current_user && timezone_difference_in_seconds(Leaderboard::GLOBAL_TIMEZONE, current_user.timezone) != 0 %>
<%= timezone_difference_in_words(Leaderboard::GLOBAL_TIMEZONE, current_user.timezone) %>
<% end %>
</p>
<div class="leaderboard-entries">
<% mini_leaderboard_entries.each_with_index do |entry, idx| %>
<% is_competition = !show_top_entries && idx >= 2 %>
<div class="leaderboard-entry <%= 'current-user' if entry.user_id == current_user&.id %>">
<% if !is_competition %>
<% rank_emoji = case entries.index(entry)
when 0 then "🥇"
when 1 then "🥈"
when 2 then "🥉"
end %>
<span class="rank"><%= rank_emoji %></span>
<% else %>
<% if idx == 2 && entries.index(entry) - entries.index(mini_leaderboard_entries[1]) > 1 %>
<div class="leaderboard-break">...</div>
<% end %>
<span class="rank"><%= (entries.index(entry) + 1).ordinalize %></span>
<% if mini_leaderboard_entries&.any? %>
<div class="mini-leaderboard">
<p class="super">
This leaderboard is in <%= Leaderboard::GLOBAL_TIMEZONE %>.
<% if current_user && timezone_difference_in_seconds(Leaderboard::GLOBAL_TIMEZONE, current_user.timezone) != 0 %>
<%= timezone_difference_in_words(Leaderboard::GLOBAL_TIMEZONE, current_user.timezone) %>
<% end %>
<span class="user">
<%= render "shared/user_mention", user: entry.user, show: [:neighborhood] %>
<% if entry.user == current_user && current_user.github_username.blank? %>
<span class="super">
<%= link_to "Link active projects", my_settings_path(anchor: "user_github_account"), target: "_blank" %>
</span>
<% end %>
<% if @active_projects&.dig(entry.user_id).present? %>
<span class="super">
working on <%= link_to @active_projects[entry.user_id].project_name, @active_projects[entry.user_id].repo_url, target: "_blank" %>
<% dev_tool(nil, 'span') do %>
<%= link_to "🌌", visualize_git_url(@active_projects[entry.user_id].repo_url), target: "_blank" %>
</p>
<div class="leaderboard-entries">
<% mini_leaderboard_entries.each_with_index do |entry, idx| %>
<% is_competition = !show_top_entries && idx >= 2 %>
<div class="leaderboard-entry <%= 'current-user' if entry.user_id == current_user&.id %>">
<% if !is_competition %>
<% rank_emoji = case entries.index(entry)
when 0 then "🥇"
when 1 then "🥈"
when 2 then "🥉"
end %>
<span class="rank"><%= rank_emoji %></span>
<% else %>
<% if idx == 2 && entries.index(entry) - entries.index(mini_leaderboard_entries[1]) > 1 %>
<div class="leaderboard-break">...</div>
<% end %>
<span class="rank"><%= (entries.index(entry) + 1).ordinalize %></span>
<% end %>
<span class="user">
<%= render "shared/user_mention", user: entry.user, show: [:neighborhood] %>
<% if entry.user == current_user && current_user.github_username.blank? %>
<span class="super">
<%= link_to "Link active projects", my_settings_path(anchor: "user_github_account"), target: "_blank" %>
</span>
<% end %>
<% if @active_projects&.dig(entry.user_id).present? %>
<span class="super">
working on <%= link_to @active_projects[entry.user_id].project_name, @active_projects[entry.user_id].repo_url, target: "_blank" %>
<% dev_tool(nil, 'span') do %>
<%= link_to "🌌", visualize_git_url(@active_projects[entry.user_id].repo_url), target: "_blank" %>
<% end %>
</span>
<% end %>
<% if entry.streak_count > 7 %>
<span class="super" title="7+ daily streak">
🔥 7+
</span>
<% elsif entry.streak_count > 0 %>
<span class="super" title="<%= entry.streak_count %> day streak">
🔥 <%= entry.streak_count %>
</span>
<% end %>
</span>
<% end %>
<% if entry.streak_count > 7 %>
<span class="super" title="7+ daily streak">
🔥 7+
</span>
<% elsif entry.streak_count > 0 %>
<span class="super" title="<%= entry.streak_count %> day streak">
🔥 <%= entry.streak_count %>
</span>
<% end %>
</span>
<span class="time"><%= short_time_detailed entry.total_seconds %></span>
<span class="time"><%= short_time_detailed entry.total_seconds %></span>
</div>
<% end %>
</div>
<% end %>
</div>
</div>
</div>
<% end %>
<% end %>
<% end %>

View File

@@ -0,0 +1,17 @@
<div class="mini-leaderboard loading">
<p class="super">
This leaderboard is in <%= Leaderboard::GLOBAL_TIMEZONE %>.
<% if current_user && timezone_difference_in_seconds(Leaderboard::GLOBAL_TIMEZONE, current_user.timezone) != 0 %>
<%= timezone_difference_in_words(Leaderboard::GLOBAL_TIMEZONE, current_user.timezone) %>
<% end %>
</p>
<div class="leaderboard-entries">
<% (current_user ? 5 : 3).times do %>
<div class="leaderboard-entry">
<span class="rank">...</span>
<span class="user">Loading...</span>
<span class="time">...</span>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,55 @@
<% content_for :title do %>
Mailroom
<% end %>
<main class="container">
<header>
<h1>The Mailroom</h1>
</header>
<% if @has_mailing_address %>
<section class="mailing-address-status">
<p class="super">
Your mailing address is set up and ready to receive mail!
<%= link_to "View my mailing address", my_mailing_address_path %>
</p>
</section>
<% else %>
<section class="mailing-address-status warning">
<h2>Mailing Address Required</h2>
<p>You need to add your mailing address for your mail to be sent.</p>
<%= link_to "Add my address", edit_my_mailing_address_path, class: "button" %>
</section>
<% end %>
<section class="physical-mail">
<h2>My Mail</h2>
<% if @physical_mails.any? %>
<div class="mail-list" style="display: flex; flex-direction: column; gap: 1.5rem; margin-top: 1rem;">
<% @physical_mails.each do |mail| %>
<% theseus_link = mail.link_to_theseus %>
<div class="mail-card" style="background: #23272f; border-radius: 12px; padding: 1.25rem 1.5rem; box-shadow: 0 2px 8px rgba(0,0,0,0.08); border: 1px solid #333; position: relative;">
<% if theseus_link %>
<a href="<%= theseus_link %>" target="_blank" style="position: absolute; inset: 0; z-index: 1;"></a>
<% end %>
<div style="position: relative; z-index: 2;">
<h3 style="margin: 0 0 0.5rem 0; font-size: 1.25rem; font-weight: 600;">
<%= mail.humanized_mission_type %>
<% if theseus_link %>
<span style="font-size: 0.9em; margin-left: 0.5em; color: #6cf;">↗</span>
<% end %>
</h3>
<p style="margin: 0.25rem 0; color: #aaa;">Status: <%= mail.status.titleize %></p>
<p style="margin: 0.25rem 0; color: #aaa;">Created: <%= mail.created_at.strftime("%B %d, %Y") %></p>
<% if theseus_link %>
<p style="margin: 0.5rem 0 0 0;"><a href="<%= theseus_link %>" target="_blank" style="color: #6cf; text-decoration: underline;">View on mail.hackclub.com</a></p>
<% end %>
</div>
</div>
<% end %>
</div>
<% else %>
<p>No mail yet. Keep coding to earn rewards!</p>
<% end %>
</section>
</main>

View File

@@ -67,6 +67,11 @@
Mailing Address
<% end %>
<% end %>
<li>
<%= link_to my_mailroom_path, class: "nav-item #{current_page?(my_mailroom_path) ? 'active' : ''}" do %>
Mailroom
<% end %>
</li>
<% admin_tool(nil, "li") do %>
<%= link_to ahoy_captain_path, class: "nav-item #{current_page?(ahoy_captain_path) ? 'active' : ''}" do %>
Ahoy Captain

View File

@@ -93,8 +93,8 @@
<% end %>
</p>
<% if @leaderboard %>
<%= render "leaderboards/mini_leaderboard", leaderboard: @leaderboard, current_user: current_user %>
<%= turbo_frame_tag "mini_leaderboard", src: mini_leaderboard_static_pages_path do %>
<%= render "leaderboards/mini_leaderboard_loading" %>
<% end %>
<%= turbo_frame_tag "filterable_dashboard", src: filterable_dashboard_static_pages_path do %>

View File

@@ -0,0 +1,32 @@
<section>
<h2 id="wakatime_mirror">WakaTime Mirror</h2>
<p>Mirror your coding activity to WakaTime.</p>
<% if current_user.wakatime_mirrors.any? %>
<div class="mirrors-list">
<% current_user.wakatime_mirrors.each do |mirror| %>
<div class="mirror-item">
<p>
<strong>Endpoint:</strong> <%= mirror.endpoint_url %><br>
<strong>Last synced:</strong> <%= mirror.last_synced_at ? time_ago_in_words(mirror.last_synced_at) + " ago" : "Never" %>
</p>
<%= button_to "Delete", user_wakatime_mirror_path(current_user, mirror), method: :delete, class: "button", data: { confirm: "Are you sure?" } %>
</div>
<% end %>
</div>
<% end %>
<%= form_with(model: [current_user, WakatimeMirror.new], local: true) do |f| %>
<div class="field">
<%= f.label :endpoint_url, "WakaTime API Endpoint" %>
<%= f.text_field :endpoint_url, value: "https://wakatime.com/api/v1", placeholder: "https://wakatime.com/api/v1" %>
</div>
<div class="field">
<%= f.label :encrypted_api_key, "WakaTime API Key" %>
<%= f.password_field :encrypted_api_key, placeholder: "Enter your WakaTime API key" %>
</div>
<%= f.submit "Add Mirror", class: "button" %>
<% end %>
</section>

View File

@@ -212,6 +212,41 @@
</p>
</section>
<% admin_tool do %>
<section>
<h2 id="wakatime_mirror">WakaTime Mirror</h2>
<p>Mirror your coding activity to WakaTime.</p>
<% if current_user.wakatime_mirrors.any? %>
<div class="mirrors-list">
<% current_user.wakatime_mirrors.each do |mirror| %>
<div class="mirror-item">
<p>
<strong>Endpoint:</strong> <%= mirror.endpoint_url %><br>
<strong>Last synced:</strong> <%= mirror.last_synced_at ? time_ago_in_words(mirror.last_synced_at) + " ago" : "Never" %>
</p>
<%= button_to "Delete", user_wakatime_mirror_path(current_user, mirror), method: :delete, class: "button", data: { confirm: "Are you sure?" } %>
</div>
<% end %>
</div>
<% end %>
<%= form_with(model: [current_user, WakatimeMirror.new], local: true) do |f| %>
<div class="field">
<%= f.label :endpoint_url, "WakaTime API Endpoint" %>
<%= f.text_field :endpoint_url, value: "https://wakatime.com/api/v1", placeholder: "https://wakatime.com/api/v1" %>
</div>
<div class="field">
<%= f.label :encrypted_api_key, "WakaTime API Key" %>
<%= f.text_field :encrypted_api_key, placeholder: "Enter your WakaTime API key" %>
</div>
<%= f.submit "Add Mirror", class: "button" %>
<% end %>
</section>
<% end %>
<section>
<h2 id="user_migration_assistant">Migration assistant</h2>
<p>This will migrate your heartbeats from waka.hackclub.com to this platform.</p>

View File

@@ -1,5 +1,28 @@
{
"ignored_warnings": [
{
"warning_type": "SQL Injection",
"warning_code": 0,
"fingerprint": "06ca3650eaeb8d28e062c1c6dcbeab95fb2ccd0c5bb49165ad469bcb6b791d3e",
"check_name": "SQL",
"message": "Possible SQL injection",
"file": "app/models/concerns/heartbeatable.rb",
"line": 214,
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
"code": "Arel.sql(\"DATE_TRUNC('day', to_timestamp(time) AT TIME ZONE '#{(user_timezone or \"UTC\")}')\")",
"render_path": null,
"location": {
"type": "method",
"class": "Heartbeatable",
"method": "daily_durations"
},
"user_input": "user_timezone",
"confidence": "Medium",
"cwe_id": [
89
],
"note": ""
},
{
"warning_type": "SQL Injection",
"warning_code": 0,

View File

@@ -42,6 +42,7 @@ Rails.application.routes.draw do
get :currently_hacking
get :filterable_dashboard_content
get :filterable_dashboard
get :mini_leaderboard
get "🃏", to: "static_pages#🃏", as: :wildcard
get :streak
# get :timeline # Removed: Old route for timeline
@@ -64,9 +65,12 @@ Rails.application.routes.draw do
# Nested under users for admin access
resources :users, only: [] do
get "settings", on: :member, to: "users#edit"
patch "settings", on: :member, to: "users#update"
member do
patch :update_trust_level
end
resource :wakatime_mirrors, only: [ :create ]
resources :wakatime_mirrors, only: [ :destroy ]
end
get "my/projects", to: "my/project_repo_mappings#index", as: :my_projects
@@ -79,6 +83,7 @@ Rails.application.routes.draw do
namespace :my do
resources :project_repo_mappings, param: :project_name, only: [ :edit, :update ]
resource :mailing_address, only: [ :show, :edit ]
get "mailroom", to: "mailroom#index"
end
get "my/wakatime_setup", to: "users#wakatime_setup"

View File

@@ -0,0 +1,14 @@
class CreateWakatimeMirrors < ActiveRecord::Migration[8.0]
def change
create_table :wakatime_mirrors do |t|
t.references :user, null: false, foreign_key: true
t.string :endpoint_url, null: false, default: "https://wakatime.com/api/v1"
t.string :encrypted_api_key, null: false
t.datetime :last_synced_at
t.timestamps
end
add_index :wakatime_mirrors, [ :user_id, :endpoint_url ], unique: true
end
end

View File

@@ -0,0 +1,10 @@
class CreateRawHeartbeatUploads < ActiveRecord::Migration[8.0]
def change
create_table :raw_heartbeat_uploads do |t|
t.jsonb :request_headers, null: false
t.jsonb :request_body, null: false
t.timestamps
end
end
end

View File

@@ -0,0 +1,5 @@
class AddRawHeartbeatUploadToHeartbeats < ActiveRecord::Migration[8.0]
def change
add_reference :heartbeats, :raw_heartbeat_upload, null: true, foreign_key: true
end
end

View File

@@ -0,0 +1,12 @@
class CreatePhysicalMails < ActiveRecord::Migration[8.0]
def change
create_table :physical_mails do |t|
t.references :user, null: false, foreign_key: true
t.integer :mission_type, null: false
t.integer :status, null: false, default: 0
t.string :theseus_id
t.timestamps
end
end
end