Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/controllers/api/admin/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,16 @@ class ApplicationController < ActionController::API

before_action :authenticate_admin!
before_action :set_paper_trail_whodunnit
around_action :allow_poisoned_heartbeats

private

def allow_poisoned_heartbeats(&block)
return yield unless ActiveModel::Type::Boolean.new.cast(params[:include_poison])

Heartbeat.including_poison(&block)
end

def authenticate_admin!
authenticate_or_request_with_http_token do |token, _|
auth_admin_api_key(token) || auth_oauth_admin(token)
Expand Down
76 changes: 76 additions & 0 deletions app/controllers/api/admin/v1/bans_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
module Api
module Admin
module V1
class BansController < Api::Admin::V1::ApplicationController
MAX_RAW_DATE_LENGTH = 40

before_action :require_superadmin
before_action :set_user

def create
cutoff = ban_date
return render_error("date is required") if cutoff.blank?

@user.apply_poison!(cutoff, reason: ban_params[:reason])

render json: {
success: true,
user_id: @user.id,
poisoned_until: @user.poisoned_until.iso8601,
poisoned_at: @user.poisoned_at.iso8601,
poison_reason: @user.poison_reason,
hidden_heartbeats: hidden_heartbeat_count
}, status: :created
rescue ArgumentError => e
if e.message.include?("future")
render_error("date cannot be in the future")
else
render_error("date is invalid")
end
end

def show
render json: {
user_id: @user.id,
poisoned: @user.poisoned?,
poisoned_until: @user.poisoned_until&.iso8601,
poisoned_at: @user.poisoned_at&.iso8601,
poison_reason: @user.poison_reason,
hidden_heartbeats: hidden_heartbeat_count
}
end

def destroy
unless @user.poisoned?
return render json: { success: true, user_id: @user.id, poisoned_until: nil, already_unbanned: true }
end

@user.remove_poison!

render json: { success: true, user_id: @user.id, poisoned_until: nil }
end

private

def set_user
@user = User.lookup_by_identifier(params[:hackatime_id].to_s)
render_not_found_json("User not found") unless @user
end

def ban_params = params.permit(:date, :end_date, :reason)

def ban_date
permitted = ban_params
return permitted[:date] if permitted[:date].present?
return permitted[:end_date] if permitted[:end_date].present?

raw = request.raw_post.to_s.strip
return if raw.blank? || raw.length > MAX_RAW_DATE_LENGTH
raw unless raw.start_with?("{", "[")
end

def hidden_heartbeat_count = Heartbeat.only_poisoned.where(user_id: @user.id).count
end
end
end
end
6 changes: 6 additions & 0 deletions app/jobs/cache/active_projects_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ def calculate
WHERE source_type = ?
AND deleted_at IS NULL
AND time > ?
AND NOT EXISTS (
SELECT 1 FROM users poisoned_users
WHERE poisoned_users.id = heartbeats.user_id
AND poisoned_users.poisoned_until IS NOT NULL
AND heartbeats.time < EXTRACT(EPOCH FROM poisoned_users.poisoned_until)
)
)
SELECT DISTINCT ON (recent.user_id) project_repo_mappings.*, recent.user_id
FROM project_repo_mappings
Expand Down
41 changes: 41 additions & 0 deletions app/models/concerns/heartbeat_poisoning.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
module HeartbeatPoisoning
extend ActiveSupport::Concern

THREAD_KEY = :hackatime_include_poisoned_heartbeats

included do
def self.poisoned_arel
users = User.arel_table
heartbeats = arel_table
cutoff_epoch = Arel::Nodes::NamedFunction.new(
"EXTRACT", [ Arel::Nodes::InfixOperation.new("FROM", Arel.sql("EPOCH"), users[:poisoned_until]) ]
)

users.project(1)
.where(users[:id].eq(heartbeats[:user_id]))
.where(users[:poisoned_until].not_eq(nil))
.where(heartbeats[:time].lt(cutoff_epoch))
.exists
end

default_scope { HeartbeatPoisoning.included_poison? ? all : where.not(poisoned_arel) }

scope :excluding_poisoned, -> { where.not(poisoned_arel) }

scope :only_poisoned, -> { unscoped.where(deleted_at: nil).where(poisoned_arel) }
end

class_methods do
def including_poison(&block) = HeartbeatPoisoning.including_poison(&block)
end

def self.included_poison? = Thread.current[THREAD_KEY].present?

def self.including_poison
previous = Thread.current[THREAD_KEY]
Thread.current[THREAD_KEY] = true
yield
ensure
Thread.current[THREAD_KEY] = previous
end
end
1 change: 1 addition & 0 deletions app/models/heartbeat.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ class Heartbeat < ApplicationRecord

include Heartbeatable
include TimeRangeFilterable
include HeartbeatPoisoning

time_range_filterable_field :time

Expand Down
2 changes: 2 additions & 0 deletions app/models/leaderboard.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ class Leaderboard < ApplicationRecord

enum :period_type, { daily: 0, last_7_days: 2 }

REBUILDABLE_PERIODS = %i[daily last_7_days].freeze

def finished_generating? = finished_generating_at.present?
def period_end_date = start_date

Expand Down
86 changes: 86 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,92 @@ def clear_leaderboard_page_cache
LeaderboardPageCache.clear!
end

scope :poisoned, -> { where.not(poisoned_until: nil) }

def poisoned? = poisoned_until.present?

def apply_poison!(cutoff, reason: nil)
raise ArgumentError, "cutoff is required" if cutoff.blank?
raise ArgumentError, "cutoff cannot be in the future" if poison_cutoff_in_future?(cutoff)

cutoff = coerce_poison_cutoff(cutoff)
raise ArgumentError, "cutoff is invalid" if cutoff.blank?

update!(poisoned_until: cutoff, poisoned_at: Time.current, poison_reason: reason.presence)
invalidate_poisoned_derived_data!
true
end

def remove_poison!
return false unless poisoned?

update!(poisoned_until: nil, poisoned_at: nil, poison_reason: nil)
invalidate_poisoned_derived_data!
true
end
private def invalidate_poisoned_derived_data!
schedule_dashboard_rollup_refresh
discard_stale_leaderboard_entries!
clear_leaderboard_page_cache
end


private def discard_stale_leaderboard_entries!
Leaderboard::REBUILDABLE_PERIODS.each do |period|
date = LeaderboardDateRange.normalize_date(Date.current, period)
board = Leaderboard.find_by(start_date: date, period_type: period, timezone_utc_offset: nil, deleted_at: nil)
LeaderboardEntry.where(user_id: id, leaderboard_id: board.id).delete_all if board

LeaderboardUpdateJob.perform_later(period, date, force_update: true)
end
end

private def poison_cutoff_in_future?(cutoff)
Time.use_zone(timezone.presence || "UTC") do
if (date = poison_cutoff_date_only(cutoff))
date > Date.current
else
instant = coerce_poison_cutoff(cutoff)
instant.present? && instant > Time.current
end
end
end

private def poison_cutoff_date_only(cutoff)
case cutoff
when DateTime then nil
when Date then cutoff
when String then Date.parse(cutoff.strip) if cutoff.strip.match?(/\A\d{4}-\d{2}-\d{2}\z/)
end
rescue Date::Error
nil
end

private def coerce_poison_cutoff(cutoff)
case cutoff
when Time, ActiveSupport::TimeWithZone, DateTime then cutoff
when Date then end_of_day_in_user_zone(cutoff)
when String then parse_poison_cutoff_string(cutoff)
end
end

private def end_of_day_in_user_zone(date)
Time.use_zone(timezone.presence || "UTC") { date.in_time_zone.beginning_of_day + 1.day }
end

private def parse_poison_cutoff_string(value)
value = value.strip
return if value.blank?

if value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
end_of_day_in_user_zone(Date.parse(value))
else
Time.use_zone(timezone.presence || "UTC") { Time.zone.parse(value) }
end
rescue Date::Error
nil
end

def schedule_leaderboard_shadowban_expiration
LeaderboardShadowbanExpirationJob.set(wait_until: leaderboard_shadowban_expires_at).perform_later(id)
end
Expand Down
8 changes: 5 additions & 3 deletions app/services/heartbeat_ingest.rb
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,12 @@ def normalize_direct_heartbeat(heartbeat, placeholder_state:)
).slice(*Heartbeat.column_names.map(&:to_sym))
end

def heartbeats_for_dedup = Heartbeat.including_poison { Heartbeat.where(user_id: @user.id) }

def persist_direct_heartbeats(entries)
entries_by_hash = entries.group_by { |entry| entry[:fields_hash] }
hashes = entries_by_hash.keys
persisted_by_hash = @user.heartbeats.where(fields_hash: hashes).index_by(&:fields_hash)
persisted_by_hash = heartbeats_for_dedup.where(fields_hash: hashes).index_by(&:fields_hash)
missing_entries = entries_by_hash.filter_map do |fields_hash, matching_entries|
matching_entries.first unless persisted_by_hash.key?(fields_hash)
end
Expand All @@ -149,7 +151,7 @@ def persist_direct_heartbeats(entries)
unresolved_hashes = missing_entries.map { |entry| entry[:fields_hash] } - inserted_by_hash.keys
if unresolved_hashes.any?
persisted_by_hash.merge!(
@user.heartbeats.where(fields_hash: unresolved_hashes).index_by(&:fields_hash)
heartbeats_for_dedup.where(fields_hash: unresolved_hashes).index_by(&:fields_hash)
)
end
end
Expand Down Expand Up @@ -275,7 +277,7 @@ def flush_import_batch(seen_hashes)
records = seen_hashes.values
compatible_hashes = records.flat_map { |record| [ record[:fields_hash], record[:legacy_fields_hash] ] }.compact.uniq
existing_hashes = compatible_hashes.each_slice(10_000).flat_map do |hashes|
@user.heartbeats.where(fields_hash: hashes).pluck(:fields_hash)
heartbeats_for_dedup.where(fields_hash: hashes).pluck(:fields_hash)
end.to_set
records = records.reject do |record|
existing_hashes.include?(record[:fields_hash]) || existing_hashes.include?(record[:legacy_fields_hash])
Expand Down
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,8 @@ def matches?(request)
post "user/search_fuzzy", to: "admin#search_users_fuzzy"
post "user/convict", to: "admin#user_convict"

resource :ban, only: [ :show, :create, :destroy ], path: "ban/:hackatime_id"

# Admin API Keys management
resources :admin_api_keys, only: [ :index, :show, :create, :destroy ]

Expand Down
9 changes: 9 additions & 0 deletions db/migrate/20260903120000_add_poison_to_users.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
class AddPoisonToUsers < ActiveRecord::Migration[8.1]
def change
add_column :users, :poisoned_until, :datetime
add_column :users, :poisoned_at, :datetime
add_column :users, :poison_reason, :text

add_index :users, :poisoned_until, where: "poisoned_until IS NOT NULL"
end
end
6 changes: 5 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading