diff --git a/chart/templates/api-deployment.yaml b/chart/templates/api-deployment.yaml index 5870dde..ed2d2fa 100644 --- a/chart/templates/api-deployment.yaml +++ b/chart/templates/api-deployment.yaml @@ -15,6 +15,21 @@ spec: labels: {{- include "josie-health.componentSelectorLabels" (dict "component" .Values.api.name) | nindent 8 }} spec: + {{- if .Values.api.migration.enabled }} + serviceAccountName: josie-health-migrate + initContainers: + - name: trigger-migration + image: image-registry.openshift-image-registry.svc:5000/openshift/cli:latest + command: + - /bin/sh + - -c + - | + oc delete job josie-health-api-migrate --ignore-not-found + oc apply -f /config/migration-job.yaml + volumeMounts: + - name: migration-job + mountPath: /config + {{- end }} containers: - name: {{ .Values.api.name }} image: {{ include "josie-health.image" (dict "root" . "image" .Values.api.image) }} @@ -37,4 +52,10 @@ spec: {{- end }} resources: {{- toYaml .Values.api.resources | nindent 12 }} + {{- if .Values.api.migration.enabled }} + volumes: + - name: migration-job + configMap: + name: josie-health-api-migration-job + {{- end }} {{- end }} diff --git a/chart/values.yaml b/chart/values.yaml index 85720d6..369a129 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -11,7 +11,7 @@ api: name: api replicas: 1 image: - name: core + name: simple-logs-api tag: latest pullPolicy: Always port: 3001 @@ -34,6 +34,8 @@ api: insecureEdgeTerminationPolicy: Redirect externalCertificate: name: josie-health-tls + migration: + enabled: true redis: enabled: true name: api-redis diff --git a/core/lib/.shards.info b/core/lib/.shards.info index 508422a..c15d7d9 100644 --- a/core/lib/.shards.info +++ b/core/lib/.shards.info @@ -21,7 +21,7 @@ shards: version: 2.9.1 josie-health-lut: git: git@github.com:josiedot/health-lut.git - version: 0.1.0+git.commit.ec2f4691c0be80e4a1cf47c736d11a3951f6d4b4 + version: 0.1.0+git.commit.9c228a52489fbfe3711889b3da8447951a86f290 josie-health-utils: git: git@github.com:josiedot/health-utils.git version: 0.1.0+git.commit.c2c3f07b62fcf4850643b346b252cd1ad8aa0094 diff --git a/core/lib/josie-health-lut/data/caffeine.json b/core/lib/josie-health-lut/data/caffeine.json index fa4e6e8..1392625 100644 --- a/core/lib/josie-health-lut/data/caffeine.json +++ b/core/lib/josie-health-lut/data/caffeine.json @@ -209,6 +209,12 @@ "default_ml": 240, "aliases": ["yerba", "mate", "miomio", "mio mio", "mio-mio"] }, + "clubmate": { + "caffeine_mg": 100, + "category": "energy_drink", + "default_ml": 500, + "aliases": ["club-mate", "clubmatte"] + }, "chai": { "caffeine_mg": 50, "category": "tea", diff --git a/core/lib/josie-health-lut/data/drugs.json b/core/lib/josie-health-lut/data/drugs.json index 9504124..9129afb 100644 --- a/core/lib/josie-health-lut/data/drugs.json +++ b/core/lib/josie-health-lut/data/drugs.json @@ -36975,5 +36975,72 @@ ], "summary": "Magnesium L-threonate is a form of magnesium that crosses the blood-brain barrier, used for cognitive function." } + }, + "kw-6356": { + "aliases": [ + "kw6356", + "sipagladenant" + ], + "categories": [ + "stimulant", + "research-chemical", + "eugeroic" + ], + "formatted_aftereffects": { + "_unit": "hours", + "value": "12-24" + }, + "formatted_dose": { + "Oral": { + "Light": "0.5-1mg", + "Common": "1-2mg", + "Strong": "2-4mg", + "Heavy": "5mg+" + } + }, + "formatted_duration": { + "_unit": "hours", + "value": "12-24" + }, + "formatted_effects": [ + "Wakefulness", + "Mental stimulation", + "Mood enhancement", + "Increased motivation", + "Dopaminergic effects", + "Improved focus" + ], + "formatted_onset": { + "_unit": "hours", + "value": "0.75-3" + }, + "links": { + "pubmed": "https://pubmed.ncbi.nlm.nih.gov/36683291/", + "everychem": "https://everychem.com" + }, + "name": "kw-6356", + "pretty_name": "KW-6356 (Sipagladenant)", + "properties": { + "after-effects": "12-24 hours", + "aliases": [ + "kw6356", + "sipagladenant" + ], + "categories": [ + "stimulant", + "research-chemical", + "eugeroic" + ], + "dose": "Oral Light: 0.5-1mg | Common: 1-2mg | Strong: 2-4mg | Heavy: 5mg+ | Clinical trials: 1-60mg single, 6-24mg daily", + "duration": "12-24 hours (may extend to 48hrs for sleep recovery)", + "half-life": "18.4-43.1 hours (mean ~22.9 hrs)", + "onset": "0.75-3 hours (Tmax)", + "receptor_dissociation": "3 hours", + "summary": "KW-6356 (Sipagladenant) is a highly selective adenosine A2A receptor inverse agonist. Like caffeine but ~100x more potent at A2A with insurmountable binding. Developed for Parkinson's disease (discontinued 2022). Now sold as nootropic for wakefulness and motivation. Key difference from caffeine: very long half-life (19+ hrs) and 3-hour receptor dissociation can significantly impair sleep.", + "mechanism": "A2A adenosine receptor inverse agonist - blocks constitutive activity and adenosine binding, releasing brake on dopaminergic/histaminergic systems", + "metabolism": "CYP3A4/5 and CYP2J2", + "warning": "Very long half-life may impair sleep up to 48hrs after dose. Use EOD or 1x/week. May cause tolerance to dopaminergic effects but not wake-promoting effects." + }, + "dose_note": " Doses approximate caffeine equivalence: 100mg caffeine ≈ 0.5mg KW, 200mg ≈ 1mg, 300mg ≈ 1.5mg, 400mg ≈ 2mg. Start LOW due to long half-life." } } \ No newline at end of file diff --git a/core/lib/josie-health-lut/src/drug_lut.cr b/core/lib/josie-health-lut/src/drug_lut.cr index 0dc6f6a..8faff63 100644 --- a/core/lib/josie-health-lut/src/drug_lut.cr +++ b/core/lib/josie-health-lut/src/drug_lut.cr @@ -176,6 +176,7 @@ module JosieHealth::LUT "ivermectin" => ["ivermectin"], "kanna" => ["kanna-tea", "kanna", "kanna100x", "kannacaffeine", "kannaciggie"], "kava" => ["kava", "kavakava", "kavalactones", "kavatea"], + "kw-6356" => ["kw6356", "sipagladenant"], "ketamine" => ["(probably s-)ket", "$k", "103mg ketamine", "50/50 ket", "50/50 mix ket", "50/50 mixed ket", "50/50mixket", "60/40 ket", "60/50 or so racemic/s ket", "70/30 mix ket", "75/25 ket", "arket", "bedtimeket", "bump ket", "ektamine", "esk", "esket.", "esket", "esketamin", "esketamine", "esketnine", "eskez", "esklet", "goodket", "groundscore-ket?", "groundscore-ketamine?", "hopefully-k..", "hopefully-k", "k 50 50", "k ektkt", "k left", "k nasal", "k question mark line", "k right", "k supplement", "k-rock", "k!", "k.", "k", "k2d3", "k374m1n3", "ka", "kalamari", "kali", "keamine", "keetyme", "ket --ago 0002", "ket + little mdma", "ket + mdma", "ket 50 mix", "ket mostly s mix", "ket new batch", "ket other batch", "ket pure batch", "ket racemic + s rests", "ket s-filtered", "ket schnozz", "ket spray", "ket-notmepersonnear", "ket!!smmam ["greenkratom.md", "greenkratom", "greenmd", "greenmdkratom", "krarom", "kratom borneo", "kratom maeng da", "kratom_white_thai", "kratom_xanax_adderall", "kratom-8%-fse", "kratom", "kratom+kava", "kratom+matcha", "kratomblend", "kratome", "kratomshake", "lightpouryellowsumatra_redmd", "red_maeng_da_kratom", "red+green+yellowkratom", "redk+s&vblend", "redkasung", "redkasung/stem&vein", "redkasung+gmd+s&v", "redkasung+s/v", "redkasung+s&v", "redkasung+yellowsumatra", "redkasyng", "redkratom", "redmaengda", "redmd", "redmd+s&v", "redmd+yellowsumatra", "redmdshake", "redmdyellowsumatra", "redmdyellowsumatragreenthai", "stim-kratom", "white-indo-kratom", "y.sumatra/r.maengda", "yellokratom", "yellowkratom oral", "yellowkratom", "yellowkratomnotmydose", "yellowkraton", "yellowsumatra.redmd", "yellowsumatra", "yellowsumatra+redmd", "yellowsumatraredmd"], "lactase" => ["lactrase", "laktase"], diff --git a/core/shard.lock b/core/shard.lock index ba073cd..59484b4 100644 --- a/core/shard.lock +++ b/core/shard.lock @@ -10,7 +10,7 @@ shards: josie-health-lut: git: git@github.com:josiedot/health-lut.git - version: 0.1.0+git.commit.ec2f4691c0be80e4a1cf47c736d11a3951f6d4b4 + version: 0.1.0+git.commit.9c228a52489fbfe3711889b3da8447951a86f290 josie-health-utils: git: git@github.com:josiedot/health-utils.git diff --git a/core/src/csv_parser.cr b/core/src/csv_parser.cr index 697f68b..fb4e8a6 100644 --- a/core/src/csv_parser.cr +++ b/core/src/csv_parser.cr @@ -25,13 +25,20 @@ module CsvParser parts end + FORMULA_PREFIXES = {'=', '+', '-', '@', '\t', '\r'} + # Escape a value for CSV output # Wraps in quotes if contains comma, quote, or newline + # Prefixes formula-starting characters with ' to prevent injection def self.escape(value : String) : String - if value.includes?(",") || value.includes?("\"") || value.includes?("\n") - "\"#{value.gsub("\"", "\"\"")}\"" + sanitized = value + if sanitized.size > 0 && FORMULA_PREFIXES.includes?(sanitized[0]) + sanitized = "'" + sanitized + end + if sanitized.includes?(",") || sanitized.includes?("\"") || sanitized.includes?("\n") + "\"#{sanitized.gsub("\"", "\"\"")}\"" else - value + sanitized end end end diff --git a/core/src/handlers/auth_handler.cr b/core/src/handlers/auth_handler.cr index af517c0..7bc4d00 100644 --- a/core/src/handlers/auth_handler.cr +++ b/core/src/handlers/auth_handler.cr @@ -558,6 +558,12 @@ class AuthHandler return end + if authenticated_user && authenticated_user != primary_user + response.status_code = 403 + response.print({"error" => "Forbidden: you can only link accounts to your own"}.to_json) + return + end + if primary_user == secondary_user response.status_code = 400 response.print({"error" => "Cannot link account to itself"}.to_json) diff --git a/core/src/handlers/log_handler.cr b/core/src/handlers/log_handler.cr index c1fcb2b..0e64ceb 100644 --- a/core/src/handlers/log_handler.cr +++ b/core/src/handlers/log_handler.cr @@ -10,15 +10,42 @@ require "../link_builder" class LogHandler Log = JosieHealth::Utils::Log + @authenticated_user : String? + # Max input lengths to prevent memory exhaustion MAX_SUBSTANCE_LENGTH = 100 MAX_DOSAGE_LENGTH = 50 MAX_ANNOTATION_LENGTH = 500 MAX_RAW_DOSE_LENGTH = 500 + MAX_FUTURE_MINUTES = 5 + MAX_PAST_YEARS = 10 + def initialize(@redis_client : RedisClient) end + private def validate_timestamp(timestamp : String, response : HTTP::Server::Response) : Bool + begin + ts = Time.parse_rfc3339(timestamp) + now = Time.utc + if ts > now + MAX_FUTURE_MINUTES.minutes + response.status_code = 400 + response.print({"error" => "Timestamp cannot be more than #{MAX_FUTURE_MINUTES} minutes in the future"}.to_json) + return false + end + if ts < now - MAX_PAST_YEARS.years + response.status_code = 400 + response.print({"error" => "Timestamp cannot be more than #{MAX_PAST_YEARS} years in the past"}.to_json) + return false + end + true + rescue + response.status_code = 400 + response.print({"error" => "Invalid timestamp format, expected RFC3339"}.to_json) + false + end + end + # Format dosage for display - removes .0 from whole numbers # 95.0 -> "95", 95.5 -> "95.5", 0.5 -> "0.5" private def format_dosage(value) : String @@ -68,7 +95,19 @@ class LogHandler end end - def handle(request : HTTP::Request, response : HTTP::Server::Response) + private def authorize_user(user_id : String, authenticated_user : String?, response : HTTP::Server::Response) : Bool + if auth_user = authenticated_user + if auth_user != user_id + response.status_code = 403 + response.print({"error" => "Forbidden: you can only access your own data"}.to_json) + return false + end + end + true + end + + def handle(request : HTTP::Request, response : HTTP::Server::Response, authenticated_user : String? = nil) + @authenticated_user = authenticated_user Log.debug("log_handler", "Handling #{request.method} #{request.path}") case request.path when "/v1/logs/substances/undose" @@ -234,7 +273,9 @@ class LogHandler # Extract common fields timestamp = data["timestamp"]?.try(&.as_s) || Time.utc.to_rfc3339 + return unless validate_timestamp(timestamp, response) user_id = data["user_id"].as_s + return unless authorize_user(user_id, @authenticated_user, response) # For raw_dose mode, unit is already embedded in dosage string unit = raw_dose_mode ? "" : (data["unit"]?.try(&.as_s) || "mg") source = data["source"]?.try(&.as_s) || "api" @@ -440,6 +481,7 @@ class LogHandler response.print({"error" => "Missing required fields: user_id and annotation"}.to_json) return end + return unless authorize_user(user_id, @authenticated_user, response) # Update the annotation on the last dose success = @redis_client.update_last_dose_annotation(user_id, note_text) @@ -476,7 +518,9 @@ class LogHandler # Extract data timestamp = data["timestamp"]?.try(&.as_s) || Time.utc.to_rfc3339 + return unless validate_timestamp(timestamp, response) user_id = data["user_id"]?.try(&.as_s) || "unknown" + return unless authorize_user(user_id, @authenticated_user, response) pulse = data["pulse"]?.try(&.as_i) systolic_bp = data["systolic_bp"]?.try(&.as_i) diastolic_bp = data["diastolic_bp"]?.try(&.as_i) @@ -542,7 +586,9 @@ class LogHandler # Extract data timestamp = data["timestamp"]?.try(&.as_s) || Time.utc.to_rfc3339 + return unless validate_timestamp(timestamp, response) user_id = data["user_id"]?.try(&.as_s) || "unknown" + return unless authorize_user(user_id, @authenticated_user, response) test_type = data["test_type"].as_s.downcase value = data["value"].to_s unit = data["unit"]?.try(&.as_s) || "" @@ -687,7 +733,7 @@ class LogHandler user_id = request.query_params["user_id"]? if user_id - # Get vitals for specific user + return unless authorize_user(user_id, @authenticated_user, response) all_vitals = @redis_client.get_user_vitals(user_id, limit + offset) else response.status_code = 400 @@ -738,7 +784,7 @@ class LogHandler test_type = request.query_params["test_type"]? if user_id - # Get labs for specific user + return unless authorize_user(user_id, @authenticated_user, response) all_labs = @redis_client.get_user_labs(user_id, limit + offset) else response.status_code = 400 @@ -791,6 +837,9 @@ class LogHandler begin # Parse query parameters user_id = request.query_params["user_id"]? + if user_id + return unless authorize_user(user_id, @authenticated_user, response) + end include_substances = request.query_params["include_substances"]? != "false" csv_output = String.build do |str| @@ -846,6 +895,7 @@ class LogHandler response.print({"error" => "Missing user_id query parameter"}.to_json) return end + return unless authorize_user(user_id, @authenticated_user, response) lines = body.split('\n').reject(&.empty?) @@ -1197,6 +1247,7 @@ class LogHandler response.print({"error" => "Missing required parameter: user_id"}.to_json) return end + return unless authorize_user(user_id, @authenticated_user, response) dose = @redis_client.get_dose(user_id, timestamp) if dose @@ -1229,6 +1280,7 @@ class LogHandler response.print({"error" => "Missing required field: user_id"}.to_json) return end + return unless authorize_user(user_id, @authenticated_user, response) # Get existing dose to validate ownership existing = @redis_client.get_dose(user_id, timestamp) @@ -1318,6 +1370,7 @@ class LogHandler response.print({"error" => "Missing required field: user_id"}.to_json) return end + return unless authorize_user(user_id, @authenticated_user, response) success = @redis_client.delete_dose(user_id, timestamp) if success @@ -1358,6 +1411,7 @@ class LogHandler end user_id = data["user_id"].as_s + return unless authorize_user(user_id, @authenticated_user, response) # Remove the last dose from Redis success = @redis_client.remove_last_dose(user_id) diff --git a/core/src/handlers/metric_handler.cr b/core/src/handlers/metric_handler.cr index 663c645..855bb5f 100644 --- a/core/src/handlers/metric_handler.cr +++ b/core/src/handlers/metric_handler.cr @@ -3,11 +3,13 @@ require "josie-health-lut" require "josie-health-utils" require "digest/sha256" require "http/client" +require "socket" require "../redis_client" require "../range_parser" class MetricHandler Log = JosieHealth::Utils::Log + @authenticated_user : String? # Wrapped service URL (legacy Ruby service for video generation) WRAPPED_SERVICE_URL = ENV["WRAPPED_SERVICE_URL"]? || "http://wrapped:3002" # Wrapped-cr service URL (Crystal service for image generation) @@ -132,7 +134,66 @@ class MetricHandler end end - def handle(request : HTTP::Request, response : HTTP::Server::Response) + private def authorize_user(user_id : String, response : HTTP::Server::Response) : Bool + if auth_user = @authenticated_user + if auth_user != user_id + response.status_code = 403 + response.print({"error" => "Forbidden: you can only access your own data"}.to_json) + return false + end + end + true + end + + ALLOWED_CALLBACK_HOSTS = ENV["ALLOWED_CALLBACK_HOSTS"]?.try(&.split(",").map(&.strip)) || ["discord-bot", "localhost"] + + private def validate_callback_url(url : String) : Bool + begin + uri = URI.parse(url) + host = uri.host + return false unless host + + # Block non-HTTP schemes + return false unless uri.scheme == "http" || uri.scheme == "https" + + # Allow configured internal service hosts + return true if ALLOWED_CALLBACK_HOSTS.includes?(host) + + # Resolve hostname and block private IP ranges + begin + addrs = Socket::Addrinfo.resolve(host, uri.port || 80, type: Socket::Type::STREAM) + addrs.each do |addr| + ip_str = addr.ip_address.address + return false if private_ip?(ip_str) + end + rescue + return false + end + + true + rescue + false + end + end + + private def private_ip?(ip : String) : Bool + parts = ip.split(".") + return true if parts.size != 4 + + a = parts[0].to_i? + b = parts[1].to_i? + return true unless a && b + + a == 127 || # 127.0.0.0/8 + a == 10 || # 10.0.0.0/8 + (a == 172 && b >= 16 && b <= 31) || # 172.16.0.0/12 + (a == 192 && b == 168) || # 192.168.0.0/16 + (a == 169 && b == 254) || # 169.254.0.0/16 + a == 0 # 0.0.0.0/8 + end + + def handle(request : HTTP::Request, response : HTTP::Server::Response, authenticated_user : String? = nil) + @authenticated_user = authenticated_user case request.path when "/v1/metrics/substances" handle_substance_metrics(response, nil) @@ -417,6 +478,7 @@ class MetricHandler end user_id = URI.decode_www_form(path_parts[4]) + return unless authorize_user(user_id, response) endpoint = path_parts[5] Log.debug("metric", "Routing to endpoint: #{endpoint}") @@ -1229,6 +1291,7 @@ class MetricHandler end user_id = URI.decode_www_form(path_parts[4]) + return unless authorize_user(user_id, response) year_or_image = path_parts[5]? image_flag = path_parts[6]? @@ -1496,6 +1559,7 @@ class MetricHandler end user_id = URI.decode_www_form(path_parts[4]) + return unless authorize_user(user_id, response) year_str = path_parts[5]? year = year_str.try(&.to_i?) || Time.utc.year @@ -1522,6 +1586,12 @@ class MetricHandler return end + unless validate_callback_url(callback_url) + response.status_code = 400 + response.print({"error" => "Invalid callback URL: must be HTTP/HTTPS and not target private networks"}.to_json) + return + end + # Check if user already has an active video generation if @redis_client.is_vwrapped_active(user_id) response.status_code = 429 @@ -1688,6 +1758,12 @@ class MetricHandler return end + unless validate_callback_url(callback_url) + response.status_code = 400 + response.print({"error" => "Invalid callback URL: must be HTTP/HTTPS and not target private networks"}.to_json) + return + end + # Determine if this is an image request is_image_request = (year_or_image == "image") || (image_flag == "image") diff --git a/core/src/main.cr b/core/src/main.cr index eb03ce1..f797a81 100644 --- a/core/src/main.cr +++ b/core/src/main.cr @@ -2,15 +2,13 @@ require "http/server" require "josie-health-utils" require "./redis_client" require "./router" +require "./migrator" -# Josie Health Core API class JosieHealthCore Log = JosieHealth::Utils::Log def initialize(@port : Int32 = 3001) - # Initialize Redis client @redis_client = RedisClient.new - @router = APIRouter.new(@redis_client) end @@ -26,5 +24,11 @@ class JosieHealthCore end end -# Start the server -JosieHealthCore.new.start +case ARGV[0]? +when "migrate" + redis_client = RedisClient.new + migrator = Migrator.new(redis_client) + exit migrator.run +else + JosieHealthCore.new.start +end diff --git a/core/src/router.cr b/core/src/router.cr index 46624fc..9c82a40 100644 --- a/core/src/router.cr +++ b/core/src/router.cr @@ -2,6 +2,7 @@ require "http/server" require "json" require "base64" require "openssl" +require "crypto/subtle" require "josie-health-utils" require "./handlers/log_handler" require "./handlers/metric_handler" @@ -40,8 +41,8 @@ class APIRouter def initialize(redis_client : RedisClient) @redis_client = redis_client - @auth_username = ENV["API_USERNAME"]? || "admin" - @auth_password = ENV["API_PASSWORD"]? || "admin" + @auth_username = ENV["API_USERNAME"]? || raise "API_USERNAME environment variable is required" + @auth_password = ENV["API_PASSWORD"]? || raise "API_PASSWORD environment variable is required" @log_handler = LogHandler.new(redis_client) @metric_handler = MetricHandler.new(redis_client) @@ -59,9 +60,20 @@ class APIRouter request = context.request response = context.response - # Add CORS headers - response.headers["Access-Control-Allow-Origin"] = "*" - response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" + # Security headers + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + response.headers["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'" + + # CORS headers + origin = request.headers["Origin"]? + allowed_origins = ["https://josie.health", "https://api.josie.health", "https://heartbeat.josie.health"] + if origin && allowed_origins.includes?(origin) + response.headers["Access-Control-Allow-Origin"] = origin + response.headers["Vary"] = "Origin" + end + response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS" response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization" if request.method == "OPTIONS" @@ -133,11 +145,11 @@ class APIRouter # Route to appropriate handlers when .starts_with?("/v1/logs") Log.debug("router", "Routing to LogHandler") - @log_handler.handle(request, response) + @log_handler.handle(request, response, @authenticated_user) when .starts_with?("/v1/analytics/") @metric_handler.handle_analytics(request, response) when .starts_with?("/v1/metrics") - @metric_handler.handle(request, response) + @metric_handler.handle(request, response, @authenticated_user) when "/v1/substances" if request.method == "GET" @substance_handler.handle_list_substances(request, response) @@ -220,7 +232,8 @@ class APIRouter decoded = Base64.decode_string(encoded) username, password = decoded.split(':', 2) - if username == @auth_username && password == @auth_password + if Crypto::Subtle.constant_time_compare(username, @auth_username) && + Crypto::Subtle.constant_time_compare(password, @auth_password) # Basic auth doesn't set authenticated_user (admin access) return true end @@ -338,10 +351,22 @@ class APIRouter end end + private def authorize_user(user_id : String, response : HTTP::Server::Response) : Bool + if auth_user = @authenticated_user + if auth_user != user_id + response.status_code = 403 + response.print({"error" => "Forbidden: you can only access your own data"}.to_json) + return false + end + end + true + end + private def handle_user_routes(request : HTTP::Request, response : HTTP::Server::Response) # /v1/users/{user_id}/timezone if match = request.path.match(/\/v1\/users\/([^\/]+)\/timezone$/) user_id = URI.decode_www_form(match[1]) + return unless authorize_user(user_id, response) case request.method when "GET" @@ -378,6 +403,7 @@ class APIRouter # /v1/users/{user_id}/data - GET stats or DELETE all user data elsif match = request.path.match(/\/v1\/users\/([^\/]+)\/data$/) user_id = URI.decode_www_form(match[1]) + return unless authorize_user(user_id, response) case request.method when "GET" diff --git a/discord-bot/Dockerfile b/discord-bot/Dockerfile index 847cd5d..674b0db 100644 --- a/discord-bot/Dockerfile +++ b/discord-bot/Dockerfile @@ -6,7 +6,7 @@ COPY . . RUN test -f .ssh/ssh-privatekey && cp .ssh/ssh-privatekey ~/.ssh/id_rsa && chmod 600 ~/.ssh/id_rsa || true RUN shards install && crystal build src/annotate_bot.cr --release --static -o annotate -FROM alpine:latest +FROM alpine:3.21 RUN apk --no-cache add ca-certificates WORKDIR /app COPY --from=builder /app/annotate . diff --git a/discord-bot/src/webhook_server.cr b/discord-bot/src/webhook_server.cr index f9a83d1..0efc381 100644 --- a/discord-bot/src/webhook_server.cr +++ b/discord-bot/src/webhook_server.cr @@ -9,6 +9,7 @@ class WebhookServer @server : HTTP::Server @discord_client : Discord::Client @pending_requests : Hash(String, PendingRequest) + @pending_mutex : Mutex struct PendingRequest property channel_id : Discord::Snowflake @@ -23,6 +24,7 @@ class WebhookServer def initialize(@discord_client : Discord::Client, port : Int32 = 8080) @pending_requests = Hash(String, PendingRequest).new + @pending_mutex = Mutex.new @server = HTTP::Server.new do |context| handle_webhook(context) @@ -48,7 +50,9 @@ class WebhookServer end def register_pending_request(request_id : String, channel_id : Discord::Snowflake, user_id : String, request_type : String) - @pending_requests[request_id] = PendingRequest.new(channel_id, user_id, request_type) + @pending_mutex.synchronize do + @pending_requests[request_id] = PendingRequest.new(channel_id, user_id, request_type) + end puts "Registered pending request: #{request_id} for #{user_id} in channel #{channel_id}" end @@ -126,7 +130,11 @@ class WebhookServer begin callback = DTO::WebhookWrappedComplete.from_json(body) - pending_request = @pending_requests[callback.request_id]? + pending_request = @pending_mutex.synchronize do + req = @pending_requests[callback.request_id]? + @pending_requests.delete(callback.request_id) if req + req + end unless pending_request puts "Received callback for unknown request_id: #{callback.request_id}" response.status_code = 404 @@ -134,8 +142,6 @@ class WebhookServer return end - @pending_requests.delete(callback.request_id) - case callback.status when "success" handle_success_callback(callback, pending_request) @@ -228,15 +234,16 @@ class WebhookServer current_time = Time.utc expired_keys = [] of String - @pending_requests.each do |request_id, pending_request| - # Remove requests older than 30 minutes - if (current_time - pending_request.created_at).total_minutes > 30 - expired_keys << request_id + @pending_mutex.synchronize do + @pending_requests.each do |request_id, pending_request| + if (current_time - pending_request.created_at).total_minutes > 30 + expired_keys << request_id + end end - end - expired_keys.each do |key| - @pending_requests.delete(key) + expired_keys.each do |key| + @pending_requests.delete(key) + end end if expired_keys.any? diff --git a/irc-bot/Dockerfile b/irc-bot/Dockerfile index f064f9f..ecc5b5e 100644 --- a/irc-bot/Dockerfile +++ b/irc-bot/Dockerfile @@ -4,7 +4,7 @@ RUN apk add --no-cache libssh2-dev libssh2-static openssl-dev openssl-libs-stati COPY . . RUN crystal build src/irc_bot.cr --release --static -o irc-bot -FROM docker.io/alpine:latest +FROM docker.io/alpine:3.21 RUN apk --no-cache add ca-certificates WORKDIR /app COPY --from=builder /app/irc-bot . diff --git a/irc-ingestion/Dockerfile b/irc-ingestion/Dockerfile index 158a371..8aaeab2 100644 --- a/irc-ingestion/Dockerfile +++ b/irc-ingestion/Dockerfile @@ -11,7 +11,7 @@ RUN apk add --no-cache libssh2-dev libssh2-static openssl-dev openssl-libs-stati rm -rf ~/.ssh .ssh RUN shards build --release --static -FROM alpine:latest +FROM alpine:3.21 RUN apk --no-cache add ca-certificates WORKDIR /app COPY --from=builder /app/bin/josie-health-irc .