Merge pull request #365 from hackclub/main

update
This commit is contained in:
Echo
2025-06-25 23:08:04 -04:00
committed by GitHub
25 changed files with 339 additions and 107 deletions

BIN
app/assets/fonts/Bold.woff Normal file

Binary file not shown.

BIN
app/assets/fonts/Bold.woff2 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -9,9 +9,50 @@
* Consider organizing styles into separate files for maintainability.
*/
/* Phantom Sans Font Faces */
@font-face {
font-family: 'Phantom Sans';
src: url('/assets/Regular.woff2') format('woff2'),
url('/assets/Regular.woff') format('woff');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Phantom Sans';
src: url('/assets/Italic.woff2') format('woff2'),
url('/assets/Italic.woff') format('woff');
font-weight: normal;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Phantom Sans';
src: url('/assets/Bold.woff2') format('woff2'),
url('/assets/Bold.woff') format('woff');
font-weight: bold;
font-style: normal;
font-display: swap;
}
@import "https://uchu.style/color.css";
@import "settings.css";
body, html {
font-family: 'Phantom Sans', 'Segoe UI', sans-serif;
}
*, *::before, *::after {
font-family: inherit;
}
code, pre, kbd, samp {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
}
/* Force dark mode for all elements */
*, *::before, *::after {
color-scheme: dark;

View File

@@ -3,6 +3,7 @@ class ApplicationController < ActionController::Base
before_action :honeybadger_context, if: :current_user
before_action :initialize_cache_counters
before_action :try_rack_mini_profiler_enable
before_action :track_request
after_action :track_action
around_action :switch_time_zone, if: :current_user
@@ -30,6 +31,10 @@ class ApplicationController < ActionController::Base
ahoy.track "Ran action", request.path_parameters
end
def track_request
RequestCounter.increment
end
def try_rack_mini_profiler_enable
if current_user && current_user.is_admin?
Rack::MiniProfiler.authorize_request

View File

@@ -92,6 +92,10 @@ class StaticPagesController < ApplicationController
@leaderboard = LeaderboardGenerator.generate_timezone_offset_leaderboard(
Date.current, current_user.timezone_utc_offset, :daily
)
if @leaderboard&.entries&.empty?
Rails.logger.warn "[MiniLeaderboard] Regional leaderboard empty for offset #{current_user.timezone_utc_offset}"
end
else
# Use global leaderboard
@leaderboard = Leaderboard.where.associated(:entries)
@@ -102,6 +106,16 @@ class StaticPagesController < ApplicationController
.first
end
if @leaderboard.nil? || @leaderboard.entries.empty?
Rails.logger.info "[MiniLeaderboard] Falling back to global leaderboard"
@leaderboard = Leaderboard.where.associated(:entries)
.where(start_date: Date.current)
.where(deleted_at: nil)
.where(period_type: :daily)
.distinct
.first
end
@active_projects = Cache::ActiveProjectsJob.perform_now
render partial: "leaderboards/mini_leaderboard", locals: {

View File

@@ -5,6 +5,11 @@ module ApplicationHelper
{ hits: hits, misses: misses }
end
def requests_per_second
rps = RequestCounter.per_second
rps == :high_load ? "lots of req/sec" : "#{rps} req/sec"
end
def admin_tool(class_name = "", element = "div", **options, &block)
return unless current_user&.is_admin?
concat content_tag(element, class: "admin-tool #{class_name}", **options, &block)

View File

@@ -0,0 +1,20 @@
class WarmMiniLeaderboardCacheJob < ApplicationJob
queue_as :default
def perform
offsets = [ -8, -7, -6, -5, -4, -3, 0, 1, 2, 8, 9, 10, 11, 12 ]
offsets.each do |offset|
begin
LeaderboardGenerator.generate_timezone_offset_leaderboard(
Date.current,
offset,
:daily
)
Rails.logger.info "Warmed mini leaderboard cache for UTC#{offset >= 0 ? '+' : ''}#{offset}"
rescue => e
Rails.logger.error "Failed to warm cache for UTC#{offset >= 0 ? '+' : ''}#{offset}: #{e.message}"
end
end
end
end

View File

@@ -0,0 +1,57 @@
class RequestCounter
WINDOW_SIZE = 10 # seconds - shorter window for more responsive rates
HIGH_LOAD_THRESHOLD = 500 # req/sec to disable tracking
CIRCUIT_BREAKER_DURATION = 30 # seconds to stay disabled
@buckets = {}
@disabled_until = nil
class << self
def increment
return if disabled?
current_time = Time.current.to_i
@buckets[current_time] = (@buckets[current_time] || 0) + 1
# Check if we should disable due to high load
check_circuit_breaker(current_time)
# Periodically clean old buckets (1% chance)
cleanup if rand(100) == 0
end
def per_second
return :high_load if disabled?
current_time = Time.current.to_i
cutoff = current_time - WINDOW_SIZE
total = @buckets.select { |timestamp, _| timestamp >= cutoff }.values.sum
(total.to_f / WINDOW_SIZE).round(2)
end
private
def disabled?
@disabled_until && Time.current.to_i < @disabled_until
end
def check_circuit_breaker(current_time)
# Check last 5 seconds for high load
recent_total = @buckets.select { |ts, _| ts >= current_time - 5 }.values.sum
if recent_total > HIGH_LOAD_THRESHOLD * 5 # 5 seconds worth
@disabled_until = current_time + CIRCUIT_BREAKER_DURATION
@buckets.clear # Clear to reduce memory
end
end
def cleanup
return if disabled? # Skip cleanup when disabled
current_time = Time.current.to_i
cutoff = current_time - WINDOW_SIZE - 10 # extra buffer
@buckets.reject! { |timestamp, _| timestamp < cutoff }
end
end
end

View File

@@ -98,9 +98,19 @@ module Heartbeatable
end
def daily_streaks_for_users(user_ids, start_date: 31.days.ago)
# First get the raw durations using window function
return {} if user_ids.empty?
start_date = [ start_date, 30.days.ago ].max
keys = user_ids.map { |id| "user_streak_#{id}" }
streak_cache = Rails.cache.read_multi(*keys)
uncached_users = user_ids.select { |id| streak_cache["user_streak_#{id}"].nil? }
if uncached_users.empty?
return user_ids.index_with { |id| streak_cache["user_streak_#{id}"] || 0 }
end
raw_durations = joins(:user)
.where(user_id: user_ids)
.where(user_id: uncached_users)
.coding_only
.with_valid_timestamps
.where(time: start_date..Time.current)
@@ -128,8 +138,7 @@ module Heartbeatable
}
end
# Initialize the result hash with zeros for all users
result = user_ids.index_with { 0 }
result = user_ids.index_with { |id| streak_cache["user_streak_#{id}"] || 0 }
# Then calculate streaks for each user
daily_durations.each do |user_id, data|
@@ -158,6 +167,9 @@ module Heartbeatable
end
result[user_id] = streak
# Cache the streak for 1 hour
Rails.cache.write("user_streak_#{user_id}", streak, expires_in: 1.hour)
end
result

View File

@@ -11,14 +11,24 @@ class LeaderboardGenerator
def generate_timezone_offset_leaderboard(date, utc_offset, period_type = :daily)
date = Date.current if date.blank?
users = User.users_in_timezone_offset(utc_offset).not_convicted
generate_leaderboard_for_users(users, date, "UTC#{utc_offset >= 0 ? '+' : ''}#{utc_offset}", period_type)
cache_key = "timezone_leaderboard_#{utc_offset}_#{date}_#{period_type}"
Rails.cache.fetch(cache_key, expires_in: 5.minutes) do
users = User.users_in_timezone_offset(utc_offset).not_convicted
generate_leaderboard_for_users(users, date, "UTC#{utc_offset >= 0 ? '+' : ''}#{utc_offset}", period_type)
end
end
def generate_timezone_leaderboard(date, timezone, period_type = :daily)
date = Date.current if date.blank?
users = User.users_in_timezone(timezone).not_convicted
generate_leaderboard_for_users(users, date, timezone, period_type)
cache_key = "timezone_leaderboard_#{timezone.gsub('/', '_')}_#{date}_#{period_type}"
Rails.cache.fetch(cache_key, expires_in: 5.minutes) do
users = User.users_in_timezone(timezone).not_convicted
generate_leaderboard_for_users(users, date, timezone, period_type)
end
end
private
@@ -34,10 +44,13 @@ class LeaderboardGenerator
finished_generating_at: Time.current
)
# Get user IDs
# Get user IDs and preload users hash for faster lookups
user_ids = users.pluck(:id)
return leaderboard if user_ids.empty?
# Preload users into a hash for O(1) lookups
users_hash = users.index_by(&:id)
# Calculate heartbeats for the date range in UTC
date_range = case period_type
when :weekly
@@ -48,7 +61,7 @@ class LeaderboardGenerator
date.all_day
end
# Get heartbeats for these users
# Get heartbeats for these users - limit to reduce query size
heartbeats = Heartbeat.where(user_id: user_ids, time: date_range)
.coding_only
.with_valid_timestamps
@@ -59,8 +72,10 @@ class LeaderboardGenerator
user_totals = heartbeats.group(:user_id).duration_seconds
user_totals = user_totals.filter { |_, total_seconds| total_seconds > 60 }
# Get streaks for all users at once
streaks = Heartbeat.daily_streaks_for_users(user_totals.keys) if user_totals.any?
# Only calculate streaks for users who actually have time today
# This significantly reduces the streak calculation overhead
streak_user_ids = user_totals.keys
streaks = streak_user_ids.any? ? Heartbeat.daily_streaks_for_users(streak_user_ids, start_date: 30.days.ago) : {}
# Create virtual leaderboard entries
entries = user_totals.map do |user_id, total_seconds|
@@ -71,8 +86,8 @@ class LeaderboardGenerator
streak_count: streaks[user_id] || 0
)
# Manually set the user association to avoid N+1 queries
entry.user = users.find { |u| u.id == user_id }
# Use preloaded users hash instead of find
entry.user = users_hash[user_id]
entry
end.sort_by(&:total_seconds).reverse

View File

@@ -16,7 +16,6 @@
<style>
.post-review-container {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
padding: 1rem; background-color: #1F2937; color: #E5E7EB;
min-height: calc(100vh - 4rem); display: flex; flex-direction: column;
}

View File

@@ -1,7 +1,7 @@
<%# app/views/admin/ysws_reviews/show.html.erb %>
<% content_for :title, "YSWS Review: #{@submission.airtable_fields['name']&.truncate(30) || 'Untitled Submission'}" %>
<div class="ysws-review-container" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; padding: 1rem; background-color: #1F2937; color: #E5E7EB; min-height: calc(100vh - 4rem);">
<div class="ysws-review-container" style="padding: 1rem; background-color: #1F2937; color: #E5E7EB; min-height: calc(100vh - 4rem);">
<div class="ysws-review-header">
<h1 style="color: #FFF; border-bottom: 1px solid #4A5568; padding-bottom: 0.5rem; margin-bottom: 1rem; font-size: 1.5rem;">
YSWS Submission Review

View File

@@ -168,6 +168,7 @@
in the last 24 hours.
(DB: <%= pluralize(QueryCount::Counter.counter, "query") %>, <%= QueryCount::Counter.counter_cache %> cached)
(CACHE: <%= cache_stats[:hits] %> hits, <%= cache_stats[:misses] %> misses)
(<%= requests_per_second %>)
</p>
<% if session[:impersonater_user_id] %>
<%= link_to "Stop impersonating", stop_impersonating_path, class: "impersonate-link", data: { turbo_prefetch: "false" } %>

View File

@@ -2,23 +2,19 @@
<% if leaderboard.present? %>
<%
entries = leaderboard.entries.respond_to?(:order) ? leaderboard.entries.order(total_seconds: :desc) : leaderboard.entries
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
top_entries = entries.first(3)
user_entry = current_user ? entries.find { |entry| entry.user_id == current_user.id } : nil
user_rank = user_entry ? entries.index(user_entry) : nil
if user_entry && user_rank && user_rank >= 3
person_above = entries[user_rank - 1] if user_rank > 0
context_entries = person_above ? [person_above, user_entry] : [user_entry]
mini_leaderboard_entries = top_entries + context_entries
show_user_separately = true
else
# Not logged in, show top 3
mini_leaderboard_entries = entries.first(3)
show_top_entries = true
mini_leaderboard_entries = top_entries
show_user_separately = false
end
%>
@@ -36,21 +32,19 @@
</p>
<div class="space-y-2">
<% mini_leaderboard_entries.each_with_index do |entry, idx| %>
<% is_competition = !show_top_entries && idx >= 2 %>
<div class="flex items-center p-3 rounded-lg hover:bg-dark transition-colors duration-200 <%= 'bg-dark border border-red' 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 %>
<div class="w-8 text-center text-lg"><%= rank_emoji %></div>
<% else %>
<% if idx == 2 && entries.index(entry) - entries.index(mini_leaderboard_entries[1]) > 1 %>
<div class="text-center text-muted font-bold tracking-widest text-lg py-1">...</div>
<% end %>
<div class="w-8 text-center text-lg font-medium text-muted"><%= entries.index(entry) + 1 %></div>
<% end %>
<%
actual_rank = entries.index(entry)
is_separator_needed = show_user_separately && idx == 3 && actual_rank > 3
%>
<div class="flex items-center p-3 rounded-lg bg-dark transition-colors duration-200 <%= 'bg-dark border border-red' if entry.user_id == current_user&.id %>">
<% rank_emoji = case actual_rank
when 0 then "🥇"
when 1 then "🥈"
when 2 then "🥉"
else actual_rank + 1
end %>
<div class="w-8 text-center text-lg"><%= rank_emoji %></div>
<div class="flex-1 mx-3 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<%= render "shared/user_mention", user: entry.user, show: [:neighborhood] %>

View File

@@ -1,4 +1,4 @@
<aside class="flex flex-col min-h-screen w-[250px] bg-dark text-white px-2 py-4 border-r border-red overflow-y-auto lg:block" data-nav-target="nav">
<aside class="flex flex-col min-h-screen w-[250px] bg-dark text-white px-2 py-4 rounded-r-lg overflow-y-auto lg:block" data-nav-target="nav" style="scrollbar-width: none; -ms-overflow-style: none;">
<div class="space-y-4">
<% flash.each do |name, msg| %>
<div>

View File

@@ -98,7 +98,6 @@
</div>
</div>
<% end %>
<p>
<% if @show_logged_time_sentence %>
You've logged
@@ -123,23 +122,20 @@
No time logged today... but you can change that!
<% end %>
</p>
<%#= turbo_frame_tag "mini_leaderboard", src: mini_leaderboard_static_pages_path do %>
<%#= render "leaderboards/mini_leaderboard_loading" %>
<%# end %>
<%= 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 %>
<span>Loading...</span>
<% end %>
<%= turbo_frame_tag "activity_graph", src: activity_graph_static_pages_path do %>
<span>Loading...</span>
<% end %>
<% else %>
<%# if @leaderboard %>
<!--<h3>Today's Top Hack Clubbers</h3>-->
<%#= render "leaderboards/mini_leaderboard", leaderboard: @leaderboard, current_user: nil %>
<%# end %>
<% if @leaderboard %>
<h3>Today's Top Hack Clubbers</h3>
<%= render "leaderboards/mini_leaderboard", leaderboard: @leaderboard, current_user: nil %>
<% end %>
<div class="login-grid">
<div class="video-container">

View File

@@ -357,25 +357,28 @@
</div>
<% if current_user.wakatime_mirrors.any? %>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<% grid_cols = current_user.wakatime_mirrors.size > 1 ? "md:grid-cols-2" : "" %>
<div class="grid grid-cols-1 <%= grid_cols %> gap-4 mb-4">
<% current_user.wakatime_mirrors.each do |mirror| %>
<div class="p-4 bg-gray-800 border border-gray-600 rounded">
<h3 class="text-white font-medium"><%= mirror.name %></h3>
<p class="text-gray-400 text-sm"><%= mirror.endpoint_url %></p>
<h3 class="text-white font-medium"><%= mirror.endpoint_url %></h3>
<p class="text-gray-400 text-sm">
Last synced: <%= mirror.last_synced_at ? time_ago_in_words(mirror.last_synced_at) + " ago" : "Never" %>
</p>
</div>
<% end %>
</div>
<% end %>
<%= form_with(model: [current_user, WakatimeMirror.new], local: true, class: "space-y-4") do |f| %>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<%= f.label :name, class: "block text-sm font-medium text-gray-200 mb-2" %>
<%= f.text_field :name, class: "w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded text-white focus:border-red focus:ring-1 focus:ring-primary" %>
</div>
<div class="grid grid-cols-1 gap-4">
<div>
<%= f.label :endpoint_url, class: "block text-sm font-medium text-gray-200 mb-2" %>
<%= f.url_field :endpoint_url, class: "w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded text-white focus:border-red focus:ring-1 focus:ring-primary" %>
<%= f.url_field :endpoint_url, value: "https://wakatime.com/api/v1", class: "w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded text-white focus:border-red focus:ring-1 focus:ring-primary" %>
</div>
<div>
<%= f.label :encrypted_api_key, "WakaTime API Key", class: "block text-sm font-medium text-gray-200 mb-2" %>
<%= f.password_field :encrypted_api_key, placeholder: "Enter your WakaTime API key", class: "w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded text-white focus:border-red focus:ring-1 focus:ring-primary" %>
</div>
</div>
<%= f.submit "Add Mirror", class: "px-4 py-2 bg-primary text-white font-medium rounded transition-colors duration-200" %>

View File

@@ -0,0 +1,11 @@
# Schedule mini leaderboard cache warming
# This will run every 5 minutes to keep the cache warm
Rails.application.config.after_initialize do
if defined?(Sidekiq::Cron::Job)
Sidekiq::Cron::Job.create(
name: "Warm Mini Leaderboard Cache",
cron: "*/5 * * * *",
class: "WarmMiniLeaderboardCacheJob"
)
end
end

View File

@@ -1,71 +1,120 @@
# config/initializers/rack_attack.rb
class Rack::Attack
# kill switch in case you really wanna
Rack::Attack.enabled = Rails.env.production? || ENV["RACK_ATTACK_ENABLED"] == "true"
Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new if Rails.env.development?
if ENV["RACK_ATTACK_BYPASS"].present?
Rack::Attack.safelist("mark any authenticated access safe") do |request|
# Requests are allowed if the return value is truthy
request.env["HTTP_RACK_ATTACK_BYPASS"] == ENV["RACK_ATTACK_BYPASS"]
begin
bypass_value = ENV["RACK_ATTACK_BYPASS"].strip
bypass_value = bypass_value.gsub(/\A['"]|['"]\z/, "")
bypass_value = bypass_value.gsub(/\\\"/, '"') if bypass_value.include?('\\\"')
TOKENS = JSON.parse(bypass_value).freeze
unless TOKENS.is_a?(Array)
Rails.logger.warn "RACK_ATTACK_BYPASS should be a array, tf is this #{TOKENS.class}"
TOKENS = [].freeze
end
Rails.logger.info "RACK_ATTACK_BYPASS loaded #{TOKENS.length} let me in tokens"
rescue JSON::ParserError => e
Rails.logger.error "RACK_ATTACK_BYPASS failed to read, you fucked it up #{e.message} raw: #{ENV['RACK_ATTACK_BYPASS'].inspect}"
TOKENS = [].freeze
end
Rack::Attack.safelist("mark any authenticated access safe") do |request|
bypass = request.env["HTTP_RACK_ATTACK_BYPASS"] || request.env["HTTP_X_RACK_ATTACK_BYPASS"]
bypass.present? && TOKENS.include?(bypass)
end
else
TOKENS = [].freeze
end
# Always allow requests from localhost
# Always allow requests from bogon ips
# (blocklist & throttles are skipped)
Rack::Attack.safelist("allow from localhost") do |req|
# Requests are allowed if the return value is truthy
"127.0.0.1" == req.ip || "::1" == req.ip
Rack::Attack.safelist("allow from bogon ips") do |req|
# max, thats a weird way, check out this method i stole from stack overflow
ip = IPAddr.new(req.ip)
ip.loopback? || ip.private?
rescue IPAddr::InvalidAddressError
false
end
# Allow an IP address to make 5 requests per second
throttle("req/ip", limit: 300, period: 5.minutes) do |req|
req.ip
Rack::Attack.throttle("general", limit: 300, period: 1.minute) do |req|
req.ip unless req.path.start_with?("/assets")
end
# Allow an IP address to make 5 POST requests per second
throttle("post/ip", limit: 60, period: 5.minutes) do |req|
Rack::Attack.throttle("posts by ip", limit: 60, period: 5.minutes) do |req|
req.ip if req.post?
end
# Throttle requests to /login by IP address
throttle("login/ip", limit: 5, period: 20.seconds) do |req|
if req.path == "/login" && req.post?
req.ip
end
Rack::Attack.throttle("auth requests", limit: 5, period: 1.minute) do |req|
req.ip if req.path.in?([ "/login", "/signup", "/auth", "/sessions" ]) && req.post?
end
# Throttle requests to /api by IP address
throttle("api/ip", limit: 100, period: 5.minutes) do |req|
if req.path.start_with?("/api")
req.ip
end
Rack::Attack.throttle("api requests", limit: 1000, period: 1.hour) do |req|
req.ip if req.path.start_with?("/api/")
end
# Log blocked requests
Rack::Attack.throttle("heartbeat api", limit: 10000, period: 1.hour) do |req|
req.ip if req.path.start_with?("/api/hackatime/v1/users/current/heartbeats")
end
Rack::Attack.blocklist("block sussy") do |req|
# somehow we can do this, so lets get all the cringe ones outta here
user_agent = req.env["HTTP_USER_AGENT"].to_s.downcase
sussy = %w[scanner bot crawler wget curl python-requests]
# if you spoof this i swear your actually cringe
no_cap = %w[wakatime hackatime github slack discord uptime kuma]
is_sus = sussy.any? { |agent| user_agent.include?(agent) }
allowed = no_cap.any? { |agent| user_agent.include?(agent) }
is_sus && !allowed
end
# lets actually log things? thanks
ActiveSupport::Notifications.subscribe(/rack_attack/) do |name, start, finish, request_id, payload|
req = payload[:request]
user_agent = req.env["HTTP_USER_AGENT"]
case name
when "rack_attack.throttle"
Rails.logger.warn "[Rack::Attack][Throttle] IP: #{req.ip}, Path: #{req.path}, Discriminator: #{payload[:discriminator]}, Matched: #{payload[:matched]}"
Rails.logger.warn "[Rack::Attack][Throttle] IP: #{req.ip}, Path: #{req.path}, Rule: #{payload[:matched]}, UA: #{user_agent}"
when "rack_attack.blocklist"
Rails.logger.warn "[Rack::Attack][Blocklist] IP: #{req.ip}, Path: #{req.path}, Discriminator: #{payload[:discriminator]}, Matched: #{payload[:matched]}"
Rails.logger.warn "[Rack::Attack][Block] IP: #{req.ip}, Path: #{req.path}, Rule: #{payload[:matched]}, UA: #{user_agent}"
when "rack_attack.safelist"
Rails.logger.info "[Rack::Attack][Safelist] IP: #{req.ip}, Path: #{req.path}, Discriminator: #{payload[:discriminator]}, Matched: #{payload[:matched]}"
Rails.logger.info "[Rack::Attack][Bypass] IP: #{req.ip}, Path: #{req.path}, Rule: #{payload[:matched]}"
end
end
# Custom response for throttled requests
self.throttled_responder = lambda do |request|
retry_after = (request.env["rack.attack.match_data"] || {})[:period]
[
429,
{
"Content-Type" => "application/json",
"Retry-After" => retry_after.to_s,
"X-RateLimit-Limit" => request.env["rack.attack.matched"].to_s,
"X-RateLimit-Remaining" => "0",
"X-RateLimit-Reset" => (Time.now + retry_after).to_i.to_s
},
[ { error: "Too Many Requests", message: "Rate limit exceeded. Try again later." }.to_json ]
]
match = request.env["rack.attack.match_data"] || {}
retry_after = match[:period] || 60
limit = match[:limit] || "unknown"
now = Time.current
reset_time = (now + retry_after).to_i
headers = {
"Content-Type" => "application/json",
"Retry-After" => retry_after.to_s,
"X-RateLimit-Limit" => limit.to_s,
"X-RateLimit-Remaining" => "0",
"X-RateLimit-Reset" => reset_time.to_s,
"X-RateLimit-Reset-At" => Time.at(reset_time).iso8601
}
res = {
error: "Rate limit exceeded",
message: "Woah there, way too fast, take a chill pill speedy gonzales!",
retry_after: retry_after,
reset_at: Time.at(reset_time).iso8601
}
[ 429, headers, [ res.to_json ] ]
end
end

View File

@@ -0,0 +1,7 @@
class AddIndexesForLeaderboardPerformance < ActiveRecord::Migration[8.0]
def change
add_index :heartbeats, [ :user_id, :time, :category ], name: 'index_heartbeats_on_user_time_category'
add_index :users, [ :timezone, :trust_level ], name: 'index_users_on_timezone_trust_level'
add_index :users, :github_uid, name: 'index_users_on_github_uid'
end
end

5
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_06_23_135342) do
ActiveRecord::Schema[8.0].define(version: 2025_06_26_001500) do
create_schema "pganalyze"
# These are extensions that must be enabled in order to support this database
@@ -248,6 +248,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_23_135342) do
t.index ["fields_hash"], name: "index_heartbeats_on_fields_hash_when_not_deleted", unique: true, where: "(deleted_at IS NULL)"
t.index ["raw_heartbeat_upload_id"], name: "index_heartbeats_on_raw_heartbeat_upload_id"
t.index ["source_type", "time", "user_id", "project"], name: "index_heartbeats_on_source_type_time_user_project"
t.index ["user_id", "time", "category"], name: "index_heartbeats_on_user_time_category"
t.index ["user_id", "time"], name: "idx_heartbeats_user_time_active", where: "(deleted_at IS NULL)"
t.index ["user_id"], name: "index_heartbeats_on_user_id"
end
@@ -495,7 +496,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_23_135342) do
t.boolean "allow_public_stats_lookup", default: true, null: false
t.boolean "default_timezone_leaderboard", default: true, null: false
t.index ["github_uid", "github_access_token"], name: "index_users_on_github_uid_and_access_token"
t.index ["github_uid"], name: "index_users_on_github_uid"
t.index ["slack_uid"], name: "index_users_on_slack_uid", unique: true
t.index ["timezone", "trust_level"], name: "index_users_on_timezone_trust_level"
t.index ["timezone"], name: "index_users_on_timezone"
end