diff --git a/app/controllers/admin/admin_api_keys_controller.rb b/app/controllers/admin/admin_api_keys_controller.rb new file mode 100644 index 0000000..991349e --- /dev/null +++ b/app/controllers/admin/admin_api_keys_controller.rb @@ -0,0 +1,41 @@ +class Admin::AdminApiKeysController < Admin::BaseController + before_action :set_admin_api_key, only: [ :show, :destroy ] + + def index + @admin_api_keys = AdminApiKey.includes(:user).active.order(created_at: :desc) + end + + def show + end + + def new + @admin_api_key = current_user.admin_api_keys.build + end + + def create + @admin_api_key = current_user.admin_api_keys.build(admin_api_key_params) + + if @admin_api_key.save + flash[:notice] = "created! now go have fun with it" + flash[:api_key_token] = @admin_api_key.token + redirect_to admin_admin_api_key_path(@admin_api_key) + else + render :new, status: :unprocessable_entity + end + end + + def destroy + @admin_api_key.revoke! + redirect_to admin_admin_api_keys_path, notice: "the key has been revoked" + end + + private + + def set_admin_api_key + @admin_api_key = AdminApiKey.find(params[:id]) + end + + def admin_api_key_params + params.require(:admin_api_key).permit(:name) + end +end diff --git a/app/controllers/api/admin/application_controller.rb b/app/controllers/api/admin/application_controller.rb new file mode 100644 index 0000000..51917f1 --- /dev/null +++ b/app/controllers/api/admin/application_controller.rb @@ -0,0 +1,40 @@ +module Api + module Admin + class ApplicationController < ActionController::API + include ActionController::HttpAuthentication::Token::ControllerMethods + + before_action :authenticate_admin_api_key! + + private + + def authenticate_admin_api_key! + authenticate_or_request_with_http_token do |token, options| + @admin_api_key = AdminApiKey.active.find_by(token: token) + + if @admin_api_key + @current_user = @admin_api_key.user + @current_user.admin? + else + false + end + end + end + + def current_user + @current_user + end + + def current_admin_api_key + @admin_api_key + end + + def render_unauthorized + render json: { error: "lmao no perms" }, status: :unauthorized + end + + def render_forbidden + render json: { error: "lmao no perms" }, status: :forbidden + end + end + end +end diff --git a/app/controllers/api/admin/v1/application_controller.rb b/app/controllers/api/admin/v1/application_controller.rb new file mode 100644 index 0000000..3faf901 --- /dev/null +++ b/app/controllers/api/admin/v1/application_controller.rb @@ -0,0 +1,9 @@ +module Api + module Admin + module V1 + class ApplicationController < Api::Admin::ApplicationController + # todo, add shit here or something + end + end + end +end diff --git a/app/controllers/api/admin/v1/stats_controller.rb b/app/controllers/api/admin/v1/stats_controller.rb new file mode 100644 index 0000000..9b81450 --- /dev/null +++ b/app/controllers/api/admin/v1/stats_controller.rb @@ -0,0 +1,74 @@ +module Api + module Admin + module V1 + class StatsController < Api::Admin::V1::ApplicationController + def index + render json: { + platform_stats: { + total_users: User.count, + admin_users: User.where(admin: true).count, + active_users_last_7_days: User.joins(:heartbeats) + .where(heartbeats: { time: 7.days.ago.. }) + .distinct.count, + active_users_last_30_days: User.joins(:heartbeats) + .where(heartbeats: { time: 30.days.ago.. }) + .distinct.count, + total_heartbeats: Heartbeat.count, + heartbeats_last_7_days: Heartbeat.where(time: 7.days.ago..).count, + heartbeats_last_30_days: Heartbeat.where(time: 30.days.ago..).count, + total_api_keys: ApiKey.count, + total_admin_api_keys: AdminApiKey.active.count, + trust_levels: User.group(:trust_level).count + }, + generated_at: Time.current + } + end + + def heartbeats + limit = [ params[:limit]&.to_i || 100, 1000 ].min + offset = params[:offset]&.to_i || 0 + + heartbeats = Heartbeat.includes(:user) + .order(time: :desc) + .limit(limit) + .offset(offset) + + if params[:user_id].present? + heartbeats = heartbeats.where(user_id: params[:user_id]) + end + + if params[:from].present? + heartbeats = heartbeats.where("time >= ?", Time.parse(params[:from])) + end + + if params[:to].present? + heartbeats = heartbeats.where("time <= ?", Time.parse(params[:to])) + end + + render json: { + heartbeats: heartbeats.map do |hb| + { + id: hb.id, + user_id: hb.user_id, + user_display_name: hb.user&.display_name, + time: hb.time, + project: hb.project, + language: hb.language, + entity: hb.entity, + duration: hb.duration, + is_debugging: hb.is_debugging + } + end, + meta: { + limit: limit, + offset: offset, + total_count: heartbeats.count + } + } + rescue ArgumentError => e + render json: { error: "tf is that date buddy #{e.message}" }, status: :bad_request + end + end + end + end +end diff --git a/app/controllers/api/admin/v1/users_controller.rb b/app/controllers/api/admin/v1/users_controller.rb new file mode 100644 index 0000000..dcec3ba --- /dev/null +++ b/app/controllers/api/admin/v1/users_controller.rb @@ -0,0 +1,120 @@ +module Api + module Admin + module V1 + class UsersController < Api::Admin::V1::ApplicationController + def index + users = User.includes(:email_addresses, :api_keys) + .order(created_at: :desc) + .limit(params[:limit]&.to_i || 50) + + if params[:admin_only] == "true" + users = users.where(admin: true) + end + + render json: { + users: users.map do |user| + { + id: user.id, + username: user.username, + display_name: user.display_name, + slack_uid: user.slack_uid, + github_username: user.github_username, + admin: user.admin?, + superadmin: user.superadmin?, + trust_level: user.trust_level, + timezone: user.timezone, + created_at: user.created_at, + last_heartbeat_at: user.heartbeats.maximum(:time), + api_keys_count: user.api_keys.count, + total_heartbeats: user.heartbeats.count + } + end, + meta: { + total_count: users.count, + limit: params[:limit]&.to_i || 50 + } + } + end + + def show + user = User.find(params[:id]) + + render json: { + user: { + id: user.id, + username: user.username, + display_name: user.display_name, + slack_uid: user.slack_uid, + slack_username: user.slack_username, + github_username: user.github_username, + admin: user.admin?, + superadmin: user.superadmin?, + trust_level: user.trust_level, + timezone: user.timezone, + country_code: user.country_code, + created_at: user.created_at, + updated_at: user.updated_at, + last_heartbeat_at: user.heartbeats.maximum(:time), + api_keys: user.api_keys.map do |key| + { + id: key.id, + name: key.name, + token_preview: "#{key.token[0..8]}...", + created_at: key.created_at + } + end, + email_addresses: user.email_addresses.map(&:email), + stats: { + total_heartbeats: user.heartbeats.count, + total_coding_time: user.heartbeats.sum(:duration) || 0, + languages_used: user.heartbeats.distinct.pluck(:language).compact.count, + projects_worked_on: user.heartbeats.distinct.pluck(:project).compact.count, + days_active: user.heartbeats.distinct.pluck(:date).compact.count + } + } + } + rescue ActiveRecord::RecordNotFound + render json: { error: "who tf is that lmao" }, status: :not_found + end + + def update_trust_level + user = User.find(params[:id]) + trust_level = params[:trust_level] + reason = params[:reason] || "updated via the api" + notes = params[:notes] + + unless User.trust_levels.key?(trust_level) + return render json: { error: "tf is that lmao" }, status: :unprocessable_entity + end + + if trust_level == "red" && !current_user.can_convict_users? + return render json: { error: "you dont got perms for that lmao" }, status: :forbidden + end + + success = user.set_trust( + trust_level, + changed_by_user: current_user, + reason: reason, + notes: notes + ) + + if success + render json: { + success: true, + message: "okay, gotcha, that user is now #{trust_level}", + user: { + id: user.id, + trust_level: user.trust_level, + updated_at: user.updated_at + } + } + else + render json: { error: "whomp that didnt work" }, status: :unprocessable_entity + end + rescue ActiveRecord::RecordNotFound + render json: { error: "who tf is that lmao" }, status: :not_found + end + end + end + end +end diff --git a/app/models/admin_api_key.rb b/app/models/admin_api_key.rb new file mode 100644 index 0000000..134b2ab --- /dev/null +++ b/app/models/admin_api_key.rb @@ -0,0 +1,25 @@ +class AdminApiKey < ApplicationRecord + belongs_to :user + + validates :token, presence: true, uniqueness: true + validates :name, presence: true, uniqueness: { scope: :user_id } + + before_validation :generate_token!, on: :create + + scope :active, -> { where(revoked_at: nil) } + + def active? + revoked_at.nil? + end + + def revoke! + update!(revoked_at: Time.current) + end + + private + + def generate_token! + # should be jazzy enough + self.token ||= "hka_#{SecureRandom.hex(32)}" + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 16cb108..f1445aa 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -73,6 +73,7 @@ class User < ApplicationRecord class_name: "Hackatime::ProjectLabel" has_many :api_keys + has_many :admin_api_keys, dependent: :destroy has_one :sailors_log, foreign_key: :slack_uid, diff --git a/app/views/admin/admin_api_keys/index.html.erb b/app/views/admin/admin_api_keys/index.html.erb new file mode 100644 index 0000000..b2f2814 --- /dev/null +++ b/app/views/admin/admin_api_keys/index.html.erb @@ -0,0 +1,77 @@ +<% content_for :title, "Admin API Keys" %> + +
+
+
+
+

admin api keys

+

fraud team is gonna foam at the mouth for this shit

+
+ <%= link_to "spawn in a new key", + new_admin_admin_api_key_path, + class: "bg-primary hover:bg-red text-white px-4 py-2 rounded-lg font-medium transition-colors" %> +
+
+ +
+
+ + + + + + + + + + + + <% @admin_api_keys.each do |api_key| %> + + + + + + + + <% end %> + +
namespawned byspawned attokenperform
+
+ <%= api_key.name %> +
+
+
+ <% if api_key.user.avatar_url %> + + <% end %> +
+
<%= api_key.user.display_name %>
+
ID: <%= api_key.user.id %>
+
+
+
+ <%= api_key.created_at.strftime("%b %d, %Y at %I:%M %p") %> + + + <%= api_key.token[0..12] %>... + + + <%= link_to "inspect", + admin_admin_api_key_path(api_key), + class: "text-blue-400 hover:text-blue-300" %> + <%= link_to "nuke", + admin_admin_api_key_path(api_key), + data: { "turbo-method": :delete }, + class: "text-red-400 hover:text-red-300" %> +
+
+ + <% if @admin_api_keys.empty? %> +
+
nothing here cuzo
+

you can make a new key if you wanna, or not

+
+ <% end %> +
+
diff --git a/app/views/admin/admin_api_keys/new.html.erb b/app/views/admin/admin_api_keys/new.html.erb new file mode 100644 index 0000000..0fb742a --- /dev/null +++ b/app/views/admin/admin_api_keys/new.html.erb @@ -0,0 +1,49 @@ +<% content_for :title, "spawn in a new key" %> + +
+
+
+
+

spawn in a new key

+

its geting real

+
+ <%= link_to "← get me outta here", + admin_admin_api_keys_path, + class: "text-gray-400 hover:text-white" %> +
+
+ +
+ <%= form_with model: [:admin, @admin_api_key], local: true, class: "space-y-6" do |f| %> + <% if @admin_api_key.errors.any? %> +
+

you done did it, fix this or else

+
    + <% @admin_api_key.errors.full_messages.each do |message| %> +
  • • <%= message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= f.label :name, class: "block text-md font-medium text-white mb-2" %> + <%= f.text_field :name, + placeholder: "put down something good please", + class: "w-full px-3 py-2 bg-darkless rounded text-white focus:border-primary focus:ring-1 focus:ring-primary" %> +
+ +
+

keep in mind this gives admin access, so dont go sharing it with your dog ya goober. we also track whatever you do, so dont go out banning people or heidi will pipebomb your sorry butt

+
+ +
+ <%= link_to "fuck no imma head out", + admin_admin_api_keys_path, + class: "px-4 py-2 text-gray-400 hover:text-white" %> + <%= f.submit "okay lets do this thing", + class: "px-6 py-2 bg-primary hover:bg-red text-white rounded-lg font-medium transition-colors" %> +
+ <% end %> +
+
diff --git a/app/views/admin/admin_api_keys/show.html.erb b/app/views/admin/admin_api_keys/show.html.erb new file mode 100644 index 0000000..74d8662 --- /dev/null +++ b/app/views/admin/admin_api_keys/show.html.erb @@ -0,0 +1,137 @@ +<% content_for :title, "Admin API Key Details" %> + +
+
+
+
+

lookin at <%= @admin_api_key.name %>

+

get the deets

+
+ <%= link_to "← Back to API Keys", + admin_admin_api_keys_path, + class: "text-gray-400 hover:text-white" %> +
+
+ +
+
+

deets

+ +
+
+ +
<%= @admin_api_key.name %>
+
+ +
+ +
+ <% if @admin_api_key.user.avatar_url %> + + <% end %> +
+
<%= @admin_api_key.user.display_name %>
+
ID: <%= @admin_api_key.user.id %>
+
+
+
+ +
+ +
<%= @admin_api_key.created_at.strftime("%B %d, %Y at %I:%M %p") %>
+
+ +
+ +
live
+
+
+ +
+ <%= link_to "nuke it", + admin_admin_api_key_path(@admin_api_key), + data: { "turbo-method": :delete }, + class: "bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg font-medium transition-colors" %> +
+
+ +
+

how 2 api

+ +
+ <% if flash[:api_key_token] %> +
+

heres your key!

+

+ copy it now, its not gonna be shown again silly +

+
+ + <%= flash[:api_key_token] %> + +
+ +
+ <% end %> + +
+ + + <%= @admin_api_key.token[0..20] %>... + + <% unless flash[:api_key_token] %> +

+ you cant see the thing again, we showed it when you created it ya doofus +

+ <% end %> +
+ +
+ +
+ + Authorization: Bearer YOUR_KEY_HERE + +
+

+ replace YOUR_KEY_HERE with your actual api key, pass it as a header and your balling +

+
+ +
+ +
+ + <%= request.protocol %><%= request.host_with_port %>/api/admin/v1/ + +
+
+
+
+
+
+ + diff --git a/app/views/shared/_nav.html.erb b/app/views/shared/_nav.html.erb index 34cc175..2b182e8 100644 --- a/app/views/shared/_nav.html.erb +++ b/app/views/shared/_nav.html.erb @@ -103,6 +103,11 @@ OAuth2 apps <% end %> <% end %> + <% admin_tool(nil, "div") do %> + <%= link_to admin_admin_api_keys_path, class: "block px-2 py-1 rounded-lg transition #{current_page?(admin_admin_api_keys_path) || request.path.start_with?('/admin/admin_api_keys') ? 'bg-primary/50 text-primary' : 'hover:bg-[#23272a]'}", data: { action: "click->nav#clickLink" } do %> + Admin API Keys + <% end %> + <% end %> <% admin_tool(nil, "div") do %> <%= link_to flipper_path, class: "block px-2 py-1 rounded-lg transition #{current_page?(flipper_path) ? 'bg-primary/50 text-primary' : 'hover:bg-[#23272a]'}", data: { action: "click->nav#clickLink" } do %> Feature Flags diff --git a/config/routes.rb b/config/routes.rb index 29d10c8..32599f9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -31,6 +31,7 @@ Rails.application.routes.draw do get "ysws_reviews/:record_id", to: "ysws_reviews#show", as: :ysws_review resources :trust_level_audit_logs, only: [ :index, :show ] + resources :admin_api_keys, except: [ :edit, :update ] end if Rails.env.development? @@ -143,6 +144,20 @@ Rails.application.routes.draw do end end + # Admin-only API namespace + namespace :admin do + namespace :v1 do + get "stats", to: "stats#index" + get "heartbeats", to: "stats#heartbeats" + + resources :users, only: [ :index, :show ] do + member do + patch :update_trust_level + end + end + end + end + # wakatime compatible summary get "summary", to: "summary#index" diff --git a/db/migrate/20250701142553_create_admin_api_keys.rb b/db/migrate/20250701142553_create_admin_api_keys.rb new file mode 100644 index 0000000..0cd8311 --- /dev/null +++ b/db/migrate/20250701142553_create_admin_api_keys.rb @@ -0,0 +1,16 @@ +class CreateAdminApiKeys < ActiveRecord::Migration[8.0] + def change + create_table :admin_api_keys do |t| + t.references :user, null: false, foreign_key: true + t.text :name, null: false + t.text :token, null: false + t.datetime :revoked_at + + t.timestamps + end + + add_index :admin_api_keys, :token, unique: true + add_index :admin_api_keys, [ :user_id, :name ], unique: true + end +end +w diff --git a/db/schema.rb b/db/schema.rb index f9b3bcf..7424f49 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,13 +10,25 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_06_30_000002) do +ActiveRecord::Schema[8.0].define(version: 2025_07_01_142553) do create_schema "pganalyze" # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_stat_statements" + create_table "admin_api_keys", force: :cascade do |t| + t.bigint "user_id", null: false + t.text "name", null: false + t.text "token", null: false + t.datetime "revoked_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["token"], name: "index_admin_api_keys_on_token", unique: true + t.index ["user_id", "name"], name: "index_admin_api_keys_on_user_id_and_name", unique: true + t.index ["user_id"], name: "index_admin_api_keys_on_user_id" + end + create_table "ahoy_events", force: :cascade do |t| t.bigint "visit_id" t.bigint "user_id" @@ -272,6 +284,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_30_000002) do t.datetime "finished_generating_at" t.datetime "deleted_at" t.integer "period_type", default: 0, null: false + t.integer "timezone_utc_offset" + t.integer "timezone_offset" + t.index ["start_date", "period_type", "timezone_offset"], name: "index_leaderboards_on_start_date_period_type_timezone_offset", where: "(deleted_at IS NULL)" t.index ["start_date"], name: "index_leaderboards_on_start_date", where: "(deleted_at IS NULL)" end @@ -551,6 +566,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_30_000002) do t.index ["user_id"], name: "index_wakatime_mirrors_on_user_id" end + add_foreign_key "admin_api_keys", "users" add_foreign_key "api_keys", "users" add_foreign_key "commits", "repositories" add_foreign_key "commits", "users"