mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 19:55:16 +00:00
Joe endpoints for fraud (#850)
type shit Co-Authored-By: ByteAtATime <byteatatime@proton.me>
This commit is contained in:
parent
3cb070e36c
commit
133f85d3a1
2 changed files with 291 additions and 0 deletions
|
|
@ -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!
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue