Joe endpoints for fraud (#850)

type shit

Co-Authored-By: ByteAtATime <byteatatime@proton.me>
This commit is contained in:
Echo 2026-01-25 23:00:12 -05:00 committed by GitHub
parent 3cb070e36c
commit 133f85d3a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 291 additions and 0 deletions

View file

@ -530,6 +530,292 @@ module Api
}
end
def visualization_quantized
user_id = params[:id]
year = params[:year]&.to_i
month = params[:month]&.to_i
if user_id.blank? || year.nil? || month.nil? || month < 1 || month > 12
render json: { error: "invalid parameters" }, status: :unprocessable_entity
return
end
begin
start_epoch = Time.utc(year, month, 1).to_i
end_epoch = if month == 12
Time.utc(year + 1, 1, 1).to_i
else
Time.utc(year, month + 1, 1).to_i
end
rescue Date::Error
render json: { error: "invalid date" }, status: :unprocessable_entity
return
end
quantized_query = <<-SQL
WITH base_heartbeats AS (
SELECT
"time",
lineno,
cursorpos,
date_trunc('day', to_timestamp("time")) as day_start
FROM heartbeats
WHERE user_id = ?
AND "time" >= ? AND "time" <= ?
AND (lineno IS NOT NULL OR cursorpos IS NOT NULL)
LIMIT 1000000
),
daily_stats AS (
SELECT
*,
GREATEST(1, MAX(lineno) OVER (PARTITION BY day_start)) as max_lineno,
GREATEST(1, MAX(cursorpos) OVER (PARTITION BY day_start)) as max_cursorpos
FROM base_heartbeats
),
quantized_heartbeats AS (
SELECT
*,
ROUND(2 + (("time" - extract(epoch from day_start)) / 86400) * (396)) as qx,
ROUND(2 + (1 - CAST(lineno AS decimal) / max_lineno) * (96)) as qy_lineno,
ROUND(2 + (1 - CAST(cursorpos AS decimal) / max_cursorpos) * (96)) as qy_cursorpos
FROM daily_stats
)
SELECT "time", lineno, cursorpos
FROM (
SELECT DISTINCT ON (day_start, qx, qy_lineno) "time", lineno, cursorpos
FROM quantized_heartbeats
WHERE lineno IS NOT NULL
ORDER BY day_start, qx, qy_lineno, "time" ASC
) AS lineno_pixels
UNION
SELECT "time", lineno, cursorpos
FROM (
SELECT DISTINCT ON (day_start, qx, qy_cursorpos) "time", lineno, cursorpos
FROM quantized_heartbeats
WHERE cursorpos IS NOT NULL
ORDER BY day_start, qx, qy_cursorpos, "time" ASC
) AS cursorpos_pixels
ORDER BY "time" ASC
SQL
daily_totals_query = <<-SQL
WITH heartbeats_with_gaps AS (
SELECT
date_trunc('day', to_timestamp("time"))::date as day,
"time" - LAG("time", 1, "time") OVER (PARTITION BY date_trunc('day', to_timestamp("time")) ORDER BY "time") as gap
FROM heartbeats
WHERE user_id = ? AND time >= ? AND time <= ?
)
SELECT
day,
SUM(LEAST(gap, 120)) as total_seconds
FROM heartbeats_with_gaps
WHERE gap IS NOT NULL
GROUP BY day
SQL
quantized_result = ActiveRecord::Base.connection.execute(
ActiveRecord::Base.sanitize_sql([ quantized_query, user_id, start_epoch, end_epoch ])
)
daily_totals_result = ActiveRecord::Base.connection.execute(
ActiveRecord::Base.sanitize_sql([ daily_totals_query, user_id, start_epoch, end_epoch ])
)
daily_totals = daily_totals_result.each_with_object({}) do |row, hash|
day = row["day"]
total_seconds = row["total_seconds"]
hash[day] = total_seconds
end
points_by_day = quantized_result.each_with_object({}) do |row, hash|
day = Time.at(row["time"]).to_date
hash[day] ||= []
hash[day] << {
time: row["time"],
lineno: row["lineno"],
cursorpos: row["cursorpos"]
}
end
days = (start_epoch...end_epoch).step(86400).map do |epoch|
day = Time.at(epoch).to_date
{
date_timestamp_s: epoch,
total_seconds: daily_totals[day] || 0,
points: points_by_day[day] || []
}
end
render json: { days: days }
end
def alt_candidates
lookback_days = (params[:lookback_days] || 30).to_i.clamp(1, 365)
cutoff = lookback_days.days.ago.to_i
query = <<-SQL
SELECT
r1.user_id AS user_a_id,
r2.user_id AS user_b_id,
r1.machine,
r1.ip_address,
r1.first_seen as user_a_first_seen_on_combo,
r1.last_seen as user_a_last_seen_on_combo,
r2.first_seen as user_b_first_seen_on_combo,
r2.last_seen as user_b_last_seen_on_combo
FROM
(
SELECT
user_id,
machine,
ip_address,
MIN(time) as first_seen,
MAX(time) as last_seen
FROM heartbeats
WHERE
user_id IS NOT NULL
AND machine IS NOT NULL
AND ip_address IS NOT NULL
AND time >= ?
GROUP BY 1, 2, 3
) r1
JOIN
(
SELECT
user_id,
machine,
ip_address,
MIN(time) as first_seen,
MAX(time) as last_seen
FROM heartbeats
WHERE
user_id IS NOT NULL
AND machine IS NOT NULL
AND ip_address IS NOT NULL
AND time >= ?
GROUP BY 1, 2, 3
) r2 ON r1.machine = r2.machine AND r1.ip_address = r2.ip_address
WHERE
r1.user_id < r2.user_id
LIMIT 5000
SQL
result = ActiveRecord::Base.connection.exec_query(
ActiveRecord::Base.sanitize_sql([ query, cutoff, cutoff ])
)
render json: { candidates: result.to_a }
end
def shared_machines
lookback_days = (params[:lookback_days] || 30).to_i.clamp(1, 365)
cutoff = lookback_days.days.ago.to_i
query = <<-SQL
SELECT
sms.machine,
sms.machine_frequency,
ARRAY_AGG(DISTINCT u.id) AS user_ids
FROM
(
SELECT
machine,
COUNT(user_id) AS machine_frequency,
ARRAY_AGG(user_id) AS user_ids
FROM
(
SELECT DISTINCT
machine,
user_id
FROM
heartbeats
WHERE
machine IS NOT NULL
AND time > ?
) AS UserMachines
GROUP BY
machine
HAVING
COUNT(user_id) > 1
) AS sms,
LATERAL UNNEST(sms.user_ids) AS user_id_from_array
JOIN
users AS u ON u.id = user_id_from_array
GROUP BY
sms.machine,
sms.machine_frequency
ORDER BY
sms.machine_frequency DESC
LIMIT 5000
SQL
result = ActiveRecord::Base.connection.exec_query(
ActiveRecord::Base.sanitize_sql([ query, cutoff ])
)
render json: { machines: result.to_a }
end
def active_users
since_ts = params[:since].to_i
min_ts = 90.days.ago.to_i
if since_ts < 0
render json: { error: "invalid since parameter" }, status: :unprocessable_entity
return
end
since_ts = [ since_ts, min_ts ].max
user_ids = Heartbeat
.where("time >= ?", since_ts)
.distinct
.limit(50_000)
.pluck(:user_id)
render json: { user_ids: user_ids }
end
def audit_logs_counts
user_ids_param = params[:user_ids]
if user_ids_param.blank? || !user_ids_param.is_a?(Array)
render json: { error: "user_ids array required" }, status: :unprocessable_entity
return
end
user_ids = user_ids_param.take(1000)
if user_ids.empty?
render json: { error: "no valid user_ids provided" }, status: :unprocessable_entity
return
end
query = <<-SQL
SELECT
user_id,
COUNT(*) AS cnt
FROM trust_level_audit_logs
WHERE user_id IN (?)
GROUP BY user_id
SQL
result = ActiveRecord::Base.connection.execute(
ActiveRecord::Base.sanitize_sql([ query, user_ids ])
)
counts = result.each_with_object({}) do |row, hash|
hash[row["user_id"].to_s] = row["cnt"]
end
user_ids.each do |id|
counts[id.to_s] ||= 0
end
render json: { counts: counts }
end
private
def can_write!

View file

@ -247,6 +247,11 @@ Rails.application.routes.draw do
get "timeline", to: "timeline#show"
get "timeline/search_users", to: "timeline#search_users"
get "timeline/leaderboard_users", to: "timeline#leaderboard_users"
get "users/:id/visualization/quantized", to: "admin#visualization_quantized"
get "alts/candidates", to: "admin#alt_candidates"
get "alts/shared_machines", to: "admin#shared_machines"
get "users/active", to: "admin#active_users"
post "audit_logs/counts", to: "admin#audit_logs_counts"
end
end