= nil, visibility : SessionVisibility = SessionVisibility::Public, list_ids : Array(String) = [] of String) : Session 259→ 259→ id = generate_id 260→ 260→ session = Session.new(id, owner_id, visibility, list_ids) 261→ 261→ @mutex.synchronize { @sessions[id] = session } 262→ 262→ session 263→ 263→ end 264→ 264→ 265→ 265→ def get(id : String) : Session? 266→ 266→ @mutex.synchronize { @sessions[id]? } 267→ 267→ end 268→ 268→ 269→ 269→ def delete(id : String) 270→ 270→ @mutex.synchronize do 271→ 271→ if session = @sessions.delete(id) 272→ 272→ session.set_inactive 273→ 273→ end 274→ 274→ end 275→ 275→ end 276→ 276→ 277→ 277→ def find_by_owner(owner_id : String) : Array(Session) 278→ 278→ @mutex.synchronize do 279→ 279→ @sessions.values.select { |s| s.owner_id == owner_id && s.active } 280→ 280→ end 281→ 281→ end 282→ 282→ 283→ 283→ private def generate_id : String 284→ 284→ bytes = Random::Secure.random_bytes(6) 285→ 285→ part1 = bytes[0, 3].hexstring[0, 4] 286→ 286→ part2 = bytes[3, 3].hexstring[0, 4] 287→ 287→ "#{part1}-#{part2}" 288→ 288→ end 289→ 289→ 290→ 290→ private def start_cleanup 291→ 291→ spawn do 292→ 292→ loop do 293→ 293→ sleep 10.minutes 294→ 294→ @mutex.synchronize do 295→ 295→ @sessions.reject! { |_, s| s.expired? } 296→ 296→ end 297→ 297→ end 298→ 298→ end 299→ 299→ end 300→ 300→end 301→ 301→ 302→ 302→SESSIONS = SessionStore.new 303→ 303→ 304→ 304→# Friend location tracking (in-memory, keyed by local_actor_id -> remote_actor_url -> location) 305→ 305→struct FriendLocation 306→ 306→ include JSON::Serializable 307→ 307→ 308→ 308→ property remote_actor_url : String 309→ 309→ property remote_actor_acct : String 310→ 310→ property lat : Float64 311→ 311→ property lng : Float64 312→ 312→ property accuracy : Float64? 313→ 313→ property speed : Float64? 314→ 314→ property heading : Float64? 315→ 315→ property updated_at : Time 316→ 316→ 317→ 317→ def initialize( 318→ 318→ @remote_actor_url : String, 319→ 319→ @remote_actor_acct : String, 320→ 320→ @lat : Float64, 321→ 321→ @lng : Float64, 322→ 322→ @accuracy : Float64? = nil, 323→ 323→ @speed : Float64? = nil, 324→ 324→ @heading : Float64? = nil, 325→ 325→ @updated_at : Time = Time.utc 326→ 326→ ) 327→ 327→ end 328→ 328→ 329→ 329→ def expired? : Bool 330→ 330→ (Time.utc - @updated_at) > 30.minutes 331→ 331→ end 332→ 332→end 333→ 333→ 334→ 334→class FriendLocationsStore 335→ 335→ def initialize 336→ 336→ # local_actor_id => { remote_actor_url => FriendLocation } 337→ 337→ @locations = {} of String => Hash(String, FriendLocation) 338→ 338→ @mutex = Mutex.new 339→ 339→ start_cleanup 340→ 340→ end 341→ 341→ 342→ 342→ def update(local_actor_id : String, location : FriendLocation) 343→ 343→ @mutex.synchronize do 344→ 344→ @locations[local_actor_id] ||= {} of String => FriendLocation 345→ 345→ @locations[local_actor_id][location.remote_actor_url] = location 346→ 346→ end 347→ 347→ end 348→ 348→ 349→ 349→ def get_all(local_actor_id : String) : Array(FriendLocation) 350→ 350→ @mutex.synchronize do 351→ 351→ if locs = @locations[local_actor_id]? 352→ 352→ locs.values.reject(&.expired?) 353→ 353→ else 354→ 354→ [] of FriendLocation 355→ 355→ end 356→ 356→ end 357→ 357→ end 358→ 358→ 359→ 359→ def remove(local_actor_id : String, remote_actor_url : String) 360→ 360→ @mutex.synchronize do 361→ 361→ if locs = @locations[local_actor_id]? 362→ 362→ locs.delete(remote_actor_url) 363→ 363→ end 364→ 364→ end 365→ 365→ end 366→ 366→ 367→ 367→ private def start_cleanup 368→ 368→ spawn do 369→ 369→ loop do 370→ 370→ sleep 5.minutes 371→ 371→ @mutex.synchronize do 372→ 372→ @locations.each do |_, locs| 373→ 373→ locs.reject! { |_, loc| loc.expired? } 374→ 374→ end 375→ 375→ @locations.reject! { |_, locs| locs.empty? } 376→ 376→ end 377→ 377→ end 378→ 378→ end 379→ 379→ end 380→ 380→end 381→ 381→ 382→ 382→FRIEND_LOCATIONS = FriendLocationsStore.new 383→ 383→ 384→ 384→# Auth helpers 385→ 385→ 386→ 386→struct AuthResult 387→ 387→ property authenticated : Bool 388→ 388→ property actor : Actor? 389→ 389→ 390→ 390→ def initialize(@authenticated, @actor = nil) 391→ 391→ end 392→ 392→end 393→ 393→ 394→ 394→def check_auth(context) : AuthResult 395→ 395→ auth_header = context.request.headers["Authorization"]? 396→ 396→ 397→ 397→ if auth_header && auth_header.starts_with?("Bearer ") 398→ 398→ token = auth_header[7..] 399→ 399→ 400→ 400→ if actor = ACTOR_STORE.find_by_token(token) 401→ 401→ return AuthResult.new(authenticated: true, actor: actor) 402→ 402→ end 403→ 403→ end 404→ 404→ 405→ 405→ AuthResult.new(authenticated: false) 406→ 406→end 407→ 407→ 408→ 408→def check_http_signature(context) : Actor? 409→ 409→ key_id = HTTPSignature.extract_key_id(context.request) 410→ 410→ return nil unless key_id 411→ 411→ 412→ 412→ # Try cache first 413→ 413→ pem = ACTOR_STORE.get_cached_key(key_id) 414→ 414→ 415→ 415→ unless pem 416→ 416→ result = Federation.fetch_public_key(key_id) 417→ 417→ return nil unless result 418→ 418→ actor_id, pem = result 419→ 419→ ACTOR_STORE.cache_remote_key(key_id, actor_id, pem) 420→ 420→ end 421→ 421→ 422→ 422→ public_key = OpenSSL::PKey::RSA.new(pem) 423→ 423→ return nil unless HTTPSignature.verify(context.request, public_key) 424→ 424→ 425→ 425→ actor_url = key_id.split("#").first 426→ 426→ actor = ACTOR_STORE.find_by_id(actor_url) 427→ 427→ return actor if actor 428→ 428→ 429→ 429→ # Fetch and cache remote actor 430→ 430→ remote_actor = Federation.fetch_actor(actor_url) 431→ 431→ return nil unless remote_actor 432→ 432→ 433→ 433→ ACTOR_STORE.upsert_remote(remote_actor) 434→ 434→end 435→ 435→ 436→ 436→def cors_headers(context, origin = "*") 437→ 437→ context.response.headers["Access-Control-Allow-Origin"] = origin 438→ 438→ context.response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS" 439→ 439→ context.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, Accept, Signature, Date, Digest" 440→ 440→ context.response.headers["Access-Control-Allow-Credentials"] = "true" 441→ 441→end 442→ 442→ 443→ 443→def extract_session_id(path : String) : String? 444→ 444→ if match = path.match(/^\/api\/session\/([a-f0-9]{4}-[a-f0-9]{4})/) 445→ 445→ match[1] 446→ 446→ else 447→ 447→ nil 448→ 448→ end 449→ 449→end 450→ 450→ 451→ 451→def extract_username(path : String) : String? 452→ 452→ if match = path.match(/^\/users\/([a-zA-Z0-9_-]+)/) 453→ 453→ match[1] 454→ 454→ else 455→ 455→ nil 456→ 456→ end 457→ 457→end 458→ 458→ 459→ 459→def wants_activitypub?(context) : Bool 460→ 460→ accept = context.request.headers["Accept"]? || "" 461→ 461→ accept.includes?("application/activity+json") || 462→ 462→ accept.includes?("application/ld+json") 463→ 463→end 464→ 464→ 465→ 465→# HTTP Server 466→ 466→ 467→ 467→server = HTTP::Server.new do |context| 468→ 468→ origin = context.request.headers["Origin"]? || "*" 469→ 469→ cors_headers(context, origin) 470→ 470→ 471→ 471→ if context.request.method == "OPTIONS" 472→ 472→ context.response.status = HTTP::Status::OK 473→ 473→ next 474→ 474→ end 475→ 475→ 476→ 476→ path = context.request.path 477→ 477→ method = context.request.method 478→ 478→ 479→ 479→ case {path, method} 480→ 480→ # ============================================ 481→ 481→ # WebFinger 482→ 482→ # ============================================ 483→ 483→ when {"/.well-known/webfinger", "GET"} 484→ 484→ resource = context.request.query_params["resource"]? 485→ 485→ unless resource 486→ 486→ context.response.status = HTTP::Status::BAD_REQUEST 487→ 487→ context.response.print(%({"error": "Missing resource parameter"})) 488→ 488→ next 489→ 489→ end 490→ 490→ 491→ 491→ # Parse acct:username@domain 492→ 492→ acct = resource.sub(/^acct:/, "") 493→ 493→ parts = acct.split("@") 494→ 494→ unless parts.size == 2 && parts[1] == DOMAIN 495→ 495→ context.response.status = HTTP::Status::NOT_FOUND 496→ 496→ context.response.print(%({"error": "User not found"})) 497→ 497→ next 498→ 498→ end 499→ 499→ 500→ 500→ username = parts[0] 501→ 501→ actor = ACTOR_STORE.find_by_username(username) 502→ 502→ unless actor 503→ 503→ context.response.status = HTTP::Status::NOT_FOUND 504→ 504→ context.response.print(%({"error": "User not found"})) 505→ 505→ next 506→ 506→ end 507→ 507→ 508→ 508→ context.response.content_type = CT_JRD 509→ 509→ context.response.print(JSON.build do |json| 510→ 510→ json.object do 511→ 511→ json.field "subject", "acct:#{actor.acct}" 512→ 512→ json.field "aliases", [actor.id] 513→ 513→ json.field "links" do 514→ 514→ json.array do 515→ 515→ json.object do 516→ 516→ json.field "rel", "self" 517→ 517→ json.field "type", CT_ACTIVITYPUB 518→ 518→ json.field "href", actor.id 519→ 519→ end 520→ 520→ json.object do 521→ 521→ json.field "rel", "http://webfinger.net/rel/profile-page" 522→ 522→ json.field "type", "text/html" 523→ 523→ json.field "href", actor.id 524→ 524→ end 525→ 525→ end 526→ 526→ end 527→ 527→ end 528→ 528→ end) 529→ 529→ 530→ 530→ # ============================================ 531→ 531→ # NodeInfo 532→ 532→ # ============================================ 533→ 533→ when {"/.well-known/nodeinfo", "GET"} 534→ 534→ context.response.content_type = CT_JSON 535→ 535→ context.response.print(JSON.build do |json| 536→ 536→ json.object do 537→ 537→ json.field "links" do 538→ 538→ json.array do 539→ 539→ json.object do 540→ 540→ json.field "rel", "http://nodeinfo.diaspora.software/ns/schema/2.1" 541→ 541→ json.field "href", "https://#{DOMAIN}/nodeinfo/2.1" 542→ 542→ end 543→ 543→ end 544→ 544→ end 545→ 545→ end 546→ 546→ end) 547→ 547→ when {"/nodeinfo/2.1", "GET"} 548→ 548→ local_users = ACTOR_STORE.find_local_actors.size 549→ 549→ context.response.content_type = CT_JSON 550→ 550→ context.response.print(JSON.build do |json| 551→ 551→ json.object do 552→ 552→ json.field "version", "2.1" 553→ 553→ json.field "software" do 554→ 554→ json.object do 555→ 555→ json.field "name", "josie.place" 556→ 556→ json.field "version", "0.2.0" 557→ 557→ json.field "repository", "https://github.com/josie/place" 558→ 558→ end 559→ 559→ end 560→ 560→ json.field "protocols", ["activitypub"] 561→ 561→ json.field "services" do 562→ 562→ json.object do 563→ 563→ json.field "outbound", [] of String 564→ 564→ json.field "inbound", [] of String 565→ 565→ end 566→ 566→ end 567→ 567→ json.field "usage" do 568→ 568→ json.object do 569→ 569→ json.field "users" do 570→ 570→ json.object do 571→ 571→ json.field "total", local_users 572→ 572→ json.field "activeMonth", local_users 573→ 573→ json.field "activeHalfyear", local_users 574→ 574→ end 575→ 575→ end 576→ 576→ json.field "localPosts", 0 577→ 577→ end 578→ 578→ end 579→ 579→ json.field "openRegistrations", !INVITE_CODES.empty? 580→ 580→ json.field "metadata" do 581→ 581→ json.object do 582→ 582→ json.field "nodeName", "josie.place" 583→ 583→ json.field "nodeDescription", "Live location sharing" 584→ 584→ end 585→ 585→ end 586→ 586→ end 587→ 587→ end) 588→ 588→ 589→ 589→ # ============================================ 590→ 590→ # Mastodon OAuth: Start login flow 591→ 591→ # ============================================ 592→ 592→ when {"/api/auth/start", "POST"} 593→ 593→ begin 594→ 594→ body = context.request.body.try(&.gets_to_end) || "" 595→ 595→ data = JSON.parse(body) 596→ 596→ 597→ 597→ instance = data["instance"]?.try(&.as_s?) 598→ 598→ unless instance 599→ 599→ context.response.status = HTTP::Status::BAD_REQUEST 600→ 600→ context.response.print(%({"error": "Missing instance parameter"})) 601→ 601→ next 602→ 602→ end 603→ 603→ 604→ 604→ is_native = data["native"]?.try(&.as_bool?) || false 605→ 605→ 606→ 606→ # Normalize instance domain 607→ 607→ instance = instance.downcase.strip 608→ 608→ instance = instance.sub(/^https?:\/\//, "").split("/").first 609→ 609→ 610→ 610→ redirect_uri = "#{API_BASE_URL}/api/auth/callback" 611→ 611→ 612→ 612→ # Detect instance type 613→ 613→ instance_type = MastodonOAuth.detect_instance_type(instance) 614→ 614→ if instance_type == MastodonOAuth::InstanceType::Unknown 615→ 615→ context.response.status = HTTP::Status::BAD_GATEWAY 616→ 616→ context.response.print(%({"error": "Unknown instance type"})) 617→ 617→ next 618→ 618→ end 619→ 619→ 620→ 620→ state = MastodonOAuth.generate_state 621→ 621→ auth_url : String 622→ 622→ 623→ 623→ if instance_type == MastodonOAuth::InstanceType::Misskey 624→ 624→ # Misskey/Sharkey: Use MiAuth (simpler and more reliable than their OAuth) 625→ 625→ session_id = MastodonOAuth.generate_miauth_session 626→ 626→ 627→ 627→ # Store session_id in code_verifier field (reusing existing schema) 628→ 628→ oauth_state = MastodonOAuth::OAuthState.new(state, instance, session_id, instance_type, is_native) 629→ 629→ ACTOR_STORE.save_oauth_state(oauth_state) 630→ 630→ 631→ 631→ # MiAuth callback includes the session ID, so we encode state in the callback URL 632→ 632→ callback_url = "#{redirect_uri}?state=#{state}" 633→ 633→ auth_url = MastodonOAuth.miauth_authorization_url(instance, session_id, callback_url) 634→ 634→ else 635→ 635→ # Mastodon: Register app and use standard OAuth 636→ 636→ app = ACTOR_STORE.get_oauth_app(instance) 637→ 637→ unless app 638→ 638→ app = MastodonOAuth.register_app(instance, redirect_uri) 639→ 639→ unless app 640→ 640→ context.response.status = HTTP::Status::BAD_GATEWAY 641→ 641→ context.response.print(%({"error": "Failed to register with instance"})) 642→ 642→ next 643→ 643→ end 644→ 644→ ACTOR_STORE.save_oauth_app(app) 645→ 645→ end 646→ 646→ 647→ 647→ oauth_state = MastodonOAuth::OAuthState.new(state, instance, nil, instance_type, is_native) 648→ 648→ ACTOR_STORE.save_oauth_state(oauth_state) 649→ 649→ 650→ 650→ auth_url = MastodonOAuth.authorization_url(instance, app.client_id, redirect_uri, state) 651→ 651→ end 652→ 652→ 653→ 653→ # Clean up old states 654→ 654→ ACTOR_STORE.cleanup_old_oauth_states 655→ 655→ 656→ 656→ context.response.content_type = CT_JSON 657→ 657→ context.response.print(%({"url": "#{auth_url}"})) 658→ 658→ rescue ex 659→ 659→ puts "Auth start error: #{ex.message}" 660→ 660→ context.response.status = HTTP::Status::BAD_REQUEST 661→ 661→ context.response.print(%({"error": "Invalid request"})) 662→ 662→ end 663→ 663→ # ============================================ 664→ 664→ # Mastodon OAuth / MiAuth: Callback 665→ 665→ # ============================================ 666→ 666→ when {"/api/auth/callback", "GET"} 667→ 667→ code = context.request.query_params["code"]? 668→ 668→ state = context.request.query_params["state"]? 669→ 669→ 670→ 670→ # state is required for both flows 671→ 671→ unless state 672→ 672→ context.response.status = HTTP::Status::BAD_REQUEST 673→ 673→ context.response.content_type = "text/html" 674→ 674→ context.response.print("
Missing state parameter
") 675→ 675→ next 676→ 676→ end 677→ 677→ 678→ 678→ # Verify state 679→ 679→ oauth_state = ACTOR_STORE.get_oauth_state(state) 680→ 680→ unless oauth_state 681→ 681→ context.response.status = HTTP::Status::BAD_REQUEST 682→ 682→ context.response.content_type = "text/html" 683→ 683→ context.response.print("Invalid or expired state
") 684→ 684→ next 685→ 685→ end 686→ 686→ 687→ 687→ # For Mastodon, code is required; for MiAuth, it's not 688→ 688→ if oauth_state.instance_type == MastodonOAuth::InstanceType::Mastodon && !code 689→ 689→ context.response.status = HTTP::Status::BAD_REQUEST 690→ 690→ context.response.content_type = "text/html" 691→ 691→ context.response.print("Missing code parameter
") 692→ 692→ next 693→ 693→ end 694→ 694→ 695→ 695→ # Delete used state 696→ 696→ ACTOR_STORE.delete_oauth_state(state) 697→ 697→ 698→ 698→ # Check state age (10 min max) 699→ 699→ if Time.utc - oauth_state.created_at > 10.minutes 700→ 700→ context.response.status = HTTP::Status::BAD_REQUEST 701→ 701→ context.response.content_type = "text/html" 702→ 702→ context.response.print("State expired
") 703→ 703→ next 704→ 704→ end 705→ 705→ 706→ 706→ instance = oauth_state.instance_domain 707→ 707→ instance_type = oauth_state.instance_type 708→ 708→ redirect_uri = "#{API_BASE_URL}/api/auth/callback" 709→ 709→ 710→ 710→ # Exchange code for token (different flow for Misskey vs Mastodon) 711→ 711→ access_token : String? 712→ 712→ account_id : String 713→ 713→ account_username : String 714→ 714→ account_display_name : String 715→ 715→ account_url : String 716→ 716→ 717→ 717→ if instance_type == MastodonOAuth::InstanceType::Misskey 718→ 718→ # Misskey/Sharkey: Use MiAuth - session_id is stored in code_verifier field 719→ 719→ session_id = oauth_state.code_verifier 720→ 720→ unless session_id 721→ 721→ context.response.status = HTTP::Status::BAD_REQUEST 722→ 722→ context.response.content_type = "text/html" 723→ 723→ context.response.print("Missing session_id for MiAuth flow
") 724→ 724→ next 725→ 725→ end 726→ 726→ 727→ 727→ # MiAuth check - no code needed, just the session ID 728→ 728→ access_token = MastodonOAuth.miauth_check(instance, session_id) 729→ 729→ unless access_token 730→ 730→ context.response.status = HTTP::Status::BAD_GATEWAY 731→ 731→ context.response.content_type = "text/html" 732→ 732→ context.response.print("Failed to get access token from MiAuth
") 733→ 733→ next 734→ 734→ end 735→ 735→ 736→ 736→ # Get Misskey user info 737→ 737→ misskey_account = MastodonOAuth.misskey_verify_credentials(instance, access_token) 738→ 738→ unless misskey_account 739→ 739→ context.response.status = HTTP::Status::BAD_GATEWAY 740→ 740→ context.response.content_type = "text/html" 741→ 741→ context.response.print("Failed to verify credentials
") 742→ 742→ next 743→ 743→ end 744→ 744→ 745→ 745→ account_id = misskey_account.id 746→ 746→ account_username = misskey_account.username 747→ 747→ account_display_name = misskey_account.name || misskey_account.username 748→ 748→ account_url = "https://#{instance}/@#{misskey_account.username}" 749→ 749→ else 750→ 750→ # Mastodon: Standard OAuth flow 751→ 751→ app = ACTOR_STORE.get_oauth_app(instance) 752→ 752→ unless app 753→ 753→ context.response.status = HTTP::Status::INTERNAL_SERVER_ERROR 754→ 754→ context.response.content_type = "text/html" 755→ 755→ context.response.print("App not found for instance
") 756→ 756→ next 757→ 757→ end 758→ 758→ 759→ 759→ access_token = MastodonOAuth.exchange_code(instance, app.client_id, app.client_secret, redirect_uri, code.not_nil!) 760→ 760→ unless access_token 761→ 761→ context.response.status = HTTP::Status::BAD_GATEWAY 762→ 762→ context.response.content_type = "text/html" 763→ 763→ context.response.print("Failed to get access token
") 764→ 764→ next 765→ 765→ end 766→ 766→ 767→ 767→ # Get Mastodon user info 768→ 768→ account = MastodonOAuth.verify_credentials(instance, access_token) 769→ 769→ unless account 770→ 770→ context.response.status = HTTP::Status::BAD_GATEWAY 771→ 771→ context.response.content_type = "text/html" 772→ 772→ context.response.print("Failed to verify credentials
") 773→ 773→ next 774→ 774→ end 775→ 775→ 776→ 776→ account_id = account.id 777→ 777→ account_username = account.username 778→ 778→ account_display_name = account.display_name 779→ 779→ account_url = account.url 780→ 780→ end 781→ 781→ 782→ 782→ # Find or create local actor 783→ 783→ actor = ACTOR_STORE.find_actor_by_remote(instance, account_id) 784→ 784→ 785→ 785→ unless actor 786→ 786→ # Create new local actor linked to this remote account 787→ 787→ # Format: username_domain_tld (e.g., josie_fedi_josie_place) 788→ 788→ username = "#{account_username}_#{instance.gsub(".", "_")}" 789→ 789→ # Ensure unique username 790→ 790→ base_username = username 791→ 791→ counter = 1 792→ 792→ while ACTOR_STORE.find_by_username(username) 793→ 793→ username = "#{base_username}#{counter}" 794→ 794→ counter += 1 795→ 795→ end 796→ 796→ 797→ 797→ actor = Actor.create_local(username, DOMAIN, account_display_name) 798→ 798→ ACTOR_STORE.create(actor) 799→ 799→ 800→ 800→ # Link to remote account 801→ 801→ link = MastodonOAuth::ActorLink.new( 802→ 802→ local_actor_id: actor.id, 803→ 803→ remote_instance: instance, 804→ 804→ remote_account_id: account_id, 805→ 805→ remote_actor_url: account_url, 806→ 806→ remote_username: account_username, 807→ 807→ access_token: access_token 808→ 808→ ) 809→ 809→ ACTOR_STORE.save_actor_link(link) 810→ 810→ end 811→ 811→ 812→ 812→ # Return response based on client type 813→ 813→ if oauth_state.is_native 814→ 814→ # Native app: redirect to custom URL scheme 815→ 815→ native_url = "josieplace://oauth-callback?token=#{actor.token}&username=#{URI.encode_path_segment(actor.username)}&actorId=#{URI.encode_path_segment(actor.id)}" 816→ 816→ context.response.status = HTTP::Status::FOUND 817→ 817→ context.response.headers["Location"] = native_url 818→ 818→ else 819→ 819→ # Web: HTML page that posts token to opener and closes 820→ 820→ context.response.content_type = "text/html" 821→ 821→ context.response.print(<<-HTML 822→ 822→ 823→ 823→ 824→ 824→You can close this window.
828→ 828→ 839→ 839→ 840→ 840→ 841→ 841→ HTML 842→ 842→ ) 843→ 843→ end 844→ 844→ 845→ 845→ # ============================================ 846→ 846→ # Shared Inbox (for efficiency) 847→ 847→ # ============================================ 848→ 848→ when {"/inbox", "POST"} 849→ 849→ actor = check_http_signature(context) 850→ 850→ unless actor 851→ 851→ context.response.status = HTTP::Status::UNAUTHORIZED 852→ 852→ context.response.print(%({"error": "Invalid signature"})) 853→ 853→ next 854→ 854→ end 855→ 855→ 856→ 856→ body = context.request.body.try(&.gets_to_end) || "" 857→ 857→ activity = Activity.from_json_any(JSON.parse(body)) 858→ 858→ unless activity 859→ 859→ context.response.status = HTTP::Status::BAD_REQUEST 860→ 860→ context.response.print(%({"error": "Invalid activity"})) 861→ 861→ next 862→ 862→ end 863→ 863→ 864→ 864→ # Route to appropriate local actor inbox 865→ 865→ # For now, just accept 866→ 866→ context.response.status = HTTP::Status::ACCEPTED 867→ 867→ context.response.print(%({"ok": true})) 868→ 868→ 869→ 869→ # ============================================ 870→ 870→ # Lists API: Get all lists 871→ 871→ # ============================================ 872→ 872→ when {"/api/lists", "GET"} 873→ 873→ auth = check_auth(context) 874→ 874→ unless auth.authenticated && auth.actor 875→ 875→ context.response.status = HTTP::Status::UNAUTHORIZED 876→ 876→ context.response.print(%({"error": "Unauthorized"})) 877→ 877→ next 878→ 878→ end 879→ 879→ 880→ 880→ lists = ACTOR_STORE.get_lists_for_actor(auth.actor.not_nil!.id) 881→ 881→ context.response.content_type = CT_JSON 882→ 882→ context.response.print(JSON.build do |json| 883→ 883→ json.array do 884→ 884→ lists.each { |list| list.to_json(json) } 885→ 885→ end 886→ 886→ end) 887→ 887→ 888→ 888→ # ============================================ 889→ 889→ # Lists API: Create a list 890→ 890→ # ============================================ 891→ 891→ when {"/api/lists", "POST"} 892→ 892→ auth = check_auth(context) 893→ 893→ unless auth.authenticated && auth.actor 894→ 894→ context.response.status = HTTP::Status::UNAUTHORIZED 895→ 895→ context.response.print(%({"error": "Unauthorized"})) 896→ 896→ next 897→ 897→ end 898→ 898→ 899→ 899→ begin 900→ 900→ body = context.request.body.try(&.gets_to_end) || "" 901→ 901→ data = JSON.parse(body) 902→ 902→ 903→ 903→ name = data["name"]?.try(&.as_s?) 904→ 904→ unless name && !name.empty? 905→ 905→ context.response.status = HTTP::Status::BAD_REQUEST 906→ 906→ context.response.print(%({"error": "Name is required"})) 907→ 907→ next 908→ 908→ end 909→ 909→ 910→ 910→ interval = data["update_interval_seconds"]?.try(&.as_i?) || 30 911→ 911→ accuracy_str = data["accuracy"]?.try(&.as_s?) || "exact" 912→ 912→ accuracy = LocationAccuracy.parse?(accuracy_str) || LocationAccuracy::Exact 913→ 913→ 914→ 914→ list = LocationList.new( 915→ 915→ id: LocationList.generate_id, 916→ 916→ owner_actor_id: auth.actor.not_nil!.id, 917→ 917→ name: name, 918→ 918→ update_interval_seconds: interval, 919→ 919→ accuracy: accuracy 920→ 920→ ) 921→ 921→ ACTOR_STORE.create_list(list) 922→ 922→ 923→ 923→ context.response.status = HTTP::Status::CREATED 924→ 924→ context.response.content_type = CT_JSON 925→ 925→ context.response.print(JSON.build { |json| list.to_json(json) }) 926→ 926→ rescue ex 927→ 927→ context.response.status = HTTP::Status::BAD_REQUEST 928→ 928→ context.response.print(%({"error": "Invalid request"})) 929→ 929→ end 930→ 930→ 931→ 931→ # ============================================ 932→ 932→ # API: Create session (with optional list_ids for federated broadcasting) 933→ 933→ # ============================================ 934→ 934→ when {"/api/session", "POST"} 935→ 935→ auth = check_auth(context) 936→ 936→ unless auth.authenticated 937→ 937→ context.response.status = HTTP::Status::UNAUTHORIZED 938→ 938→ context.response.print(%({"error": "Unauthorized"})) 939→ 939→ next 940→ 940→ end 941→ 941→ 942→ 942→ owner_id = auth.actor.try(&.id) 943→ 943→ list_ids = [] of String 944→ 944→ 945→ 945→ # Parse optional list_ids from request body 946→ 946→ begin 947→ 947→ body = context.request.body.try(&.gets_to_end) || "" 948→ 948→ unless body.empty? 949→ 949→ data = JSON.parse(body) 950→ 950→ if ids = data["list_ids"]?.try(&.as_a?) 951→ 951→ list_ids = ids.compact_map(&.as_s?) 952→ 952→ end 953→ 953→ end 954→ 954→ rescue 955→ 955→ # Ignore parse errors, just use empty list_ids 956→ 956→ end 957→ 957→ 958→ 958→ session = SESSIONS.create(owner_id, SessionVisibility::Public, list_ids) 959→ 959→ context.response.content_type = CT_JSON 960→ 960→ context.response.print(JSON.build do |json| 961→ 961→ json.object do 962→ 962→ json.field "id", session.id 963→ 963→ json.field "list_ids", list_ids 964→ 964→ end 965→ 965→ end) 966→ 966→ 967→ 967→ # ============================================ 968→ 968→ # Health check 969→ 969→ # ============================================ 970→ 970→ when {"/health", "GET"} 971→ 971→ context.response.content_type = CT_JSON 972→ 972→ context.response.print(%({"status": "ok"})) 973→ 973→ 974→ 974→ # ============================================ 975→ 975→ # Test endpoint: Add fake friend location (dev only) 976→ 976→ # ============================================ 977→ 977→ when {"/api/test/friend-location", "POST"} 978→ 978→ auth = check_auth(context) 979→ 979→ unless auth.authenticated && auth.actor 980→ 980→ context.response.status = HTTP::Status::UNAUTHORIZED 981→ 981→ context.response.print(%({"error": "Unauthorized"})) 982→ 982→ next 983→ 983→ end 984→ 984→ 985→ 985→ begin 986→ 986→ body = context.request.body.try(&.gets_to_end) || "" 987→ 987→ data = JSON.parse(body) 988→ 988→ 989→ 989→ actor = auth.actor.not_nil! 990→ 990→ friend_loc = FriendLocation.new( 991→ 991→ remote_actor_url: data["actor_url"].as_s, 992→ 992→ remote_actor_acct: data["acct"].as_s, 993→ 993→ lat: data["lat"].as_f, 994→ 994→ lng: data["lng"].as_f, 995→ 995→ accuracy: data["accuracy"]?.try(&.as_f?), 996→ 996→ updated_at: Time.utc 997→ 997→ ) 998→ 998→ FRIEND_LOCATIONS.update(actor.id, friend_loc) 999→ 999→ 1000→ 1000→ context.response.content_type = CT_JSON 1001→ 1001→ context.response.print(%({"ok": true})) 1002→ 1002→ rescue ex 1003→ 1003→ context.response.status = HTTP::Status::BAD_REQUEST 1004→ 1004→ context.response.print(%({"error": "#{ex.message}"})) 1005→ 1005→ end 1006→ 1006→ else 1007→ 1007→ # ============================================ 1008→ 1008→ # Lists API: /api/lists/:id/* 1009→ 1009→ # ============================================ 1010→ 1010→ if path.starts_with?("/api/lists/") 1011→ 1011→ auth = check_auth(context) 1012→ 1012→ unless auth.authenticated && auth.actor 1013→ 1013→ context.response.status = HTTP::Status::UNAUTHORIZED 1014→ 1014→ context.response.print(%({"error": "Unauthorized"})) 1015→ 1015→ next 1016→ 1016→ end 1017→ 1017→ 1018→ 1018→ actor = auth.actor.not_nil! 1019→ 1019→ parts = path.sub("/api/lists/", "").split("/") 1020→ 1020→ list_id = parts[0] 1021→ 1021→ subpath = parts.size > 1 ? "/#{parts[1..-1].join("/")}" : "" 1022→ 1022→ 1023→ 1023→ list = ACTOR_STORE.get_list(list_id) 1024→ 1024→ unless list && list.owner_actor_id == actor.id 1025→ 1025→ context.response.status = HTTP::Status::NOT_FOUND 1026→ 1026→ context.response.print(%({"error": "List not found"})) 1027→ 1027→ next 1028→ 1028→ end 1029→ 1029→ 1030→ 1030→ case {subpath, method} 1031→ 1031→ # Get single list 1032→ 1032→ when {"", "GET"} 1033→ 1033→ context.response.content_type = CT_JSON 1034→ 1034→ context.response.print(JSON.build { |json| list.to_json(json) }) 1035→ 1035→ 1036→ 1036→ # Update list 1037→ 1037→ when {"", "PATCH"} 1038→ 1038→ begin 1039→ 1039→ body = context.request.body.try(&.gets_to_end) || "" 1040→ 1040→ data = JSON.parse(body) 1041→ 1041→ 1042→ 1042→ updated_list = LocationList.new( 1043→ 1043→ id: list.id, 1044→ 1044→ owner_actor_id: list.owner_actor_id, 1045→ 1045→ name: data["name"]?.try(&.as_s?) || list.name, 1046→ 1046→ update_interval_seconds: data["update_interval_seconds"]?.try(&.as_i?) || list.update_interval_seconds, 1047→ 1047→ accuracy: data["accuracy"]?.try(&.as_s?).try { |s| LocationAccuracy.parse?(s) } || list.accuracy, 1048→ 1048→ created_at: list.created_at, 1049→ 1049→ updated_at: Time.utc 1050→ 1050→ ) 1051→ 1051→ ACTOR_STORE.update_list(updated_list) 1052→ 1052→ 1053→ 1053→ context.response.content_type = CT_JSON 1054→ 1054→ context.response.print(JSON.build { |json| updated_list.to_json(json) }) 1055→ 1055→ rescue ex 1056→ 1056→ context.response.status = HTTP::Status::BAD_REQUEST 1057→ 1057→ context.response.print(%({"error": "Invalid request"})) 1058→ 1058→ end 1059→ 1059→ # Delete list 1060→ 1060→ when {"", "DELETE"} 1061→ 1061→ ACTOR_STORE.delete_list(list_id) 1062→ 1062→ context.response.status = HTTP::Status::NO_CONTENT 1063→ 1063→ 1064→ 1064→ # Get list members 1065→ 1065→ when {"/members", "GET"} 1066→ 1066→ members = ACTOR_STORE.get_list_members(list_id) 1067→ 1067→ context.response.content_type = CT_JSON 1068→ 1068→ context.response.print(JSON.build do |json| 1069→ 1069→ json.array do 1070→ 1070→ members.each { |m| m.to_json(json) } 1071→ 1071→ end 1072→ 1072→ end) 1073→ 1073→ 1074→ 1074→ # Add member to list 1075→ 1075→ when {"/members", "POST"} 1076→ 1076→ begin 1077→ 1077→ body = context.request.body.try(&.gets_to_end) || "" 1078→ 1078→ data = JSON.parse(body) 1079→ 1079→ 1080→ 1080→ acct = data["acct"]?.try(&.as_s?) 1081→ 1081→ unless acct && !acct.empty? 1082→ 1082→ context.response.status = HTTP::Status::BAD_REQUEST 1083→ 1083→ context.response.print(%({"error": "acct is required (e.g., user@example.com)"})) 1084→ 1084→ next 1085→ 1085→ end 1086→ 1086→ 1087→ 1087→ # Normalize acct: trim whitespace and remove leading @ 1088→ 1088→ acct = acct.strip 1089→ 1089→ if acct.starts_with?('@') 1090→ 1090→ acct = acct[1..] 1091→ 1091→ end 1092→ 1092→ 1093→ 1093→ puts "Adding member: acct=#{acct}" 1094→ 1094→ 1095→ 1095→ # Check if already a member 1096→ 1096→ existing = ACTOR_STORE.get_list_members(list_id).find { |m| m.remote_actor_acct == acct } 1097→ 1097→ if existing 1098→ 1098→ context.response.status = HTTP::Status::CONFLICT 1099→ 1099→ context.response.print(%({"error": "Already a member"})) 1100→ 1100→ next 1101→ 1101→ end 1102→ 1102→ 1103→ 1103→ # Validate acct format 1104→ 1104→ parts = acct.split("@") 1105→ 1105→ if parts.size != 2 1106→ 1106→ context.response.status = HTTP::Status::BAD_REQUEST 1107→ 1107→ context.response.print(%({"error": "Invalid acct format"})) 1108→ 1108→ next 1109→ 1109→ end 1110→ 1110→ 1111→ 1111→ # Lookup actor via WebFinger 1112→ 1112→ remote_actor_url = Federation.webfinger(acct) 1113→ 1113→ unless remote_actor_url 1114→ 1114→ context.response.status = HTTP::Status::NOT_FOUND 1115→ 1115→ context.response.print(%({"error": "Could not find user via WebFinger"})) 1116→ 1116→ next 1117→ 1117→ end 1118→ 1118→ 1119→ 1119→ # Fetch remote actor to get their inbox (signed for servers that require it) 1120→ 1120→ remote_actor = Federation.fetch_actor(remote_actor_url, actor) 1121→ 1121→ unless remote_actor 1122→ 1122→ context.response.status = HTTP::Status::NOT_FOUND 1123→ 1123→ context.response.print(%({"error": "Could not fetch remote actor"})) 1124→ 1124→ next 1125→ 1125→ end 1126→ 1126→ 1127→ 1127→ member = ListMember.new( 1128→ 1128→ id: ListMember.generate_id, 1129→ 1129→ list_id: list_id, 1130→ 1130→ remote_actor_url: remote_actor_url, 1131→ 1131→ remote_actor_acct: acct, 1132→ 1132→ status: "pending" 1133→ 1133→ ) 1134→ 1134→ ACTOR_STORE.add_list_member(member) 1135→ 1135→ 1136→ 1136→ # Check if this remote user has a local josie.place account 1137→ 1137→ # Use username/domain since actor_links stores web profile URL, not ActivityPub URL 1138→ 1138→ local_recipient = ACTOR_STORE.find_local_actor_by_remote_acct(parts[0], parts[1]) 1139→ 1139→ if local_recipient 1140→ 1140→ # Create IncomingShare directly for local user 1141→ 1141→ puts "Creating local incoming share for #{local_recipient.username}" 1142→ 1142→ existing_share = ACTOR_STORE.find_incoming_share_by_actor(local_recipient.id, actor.id) 1143→ 1143→ unless existing_share 1144→ 1144→ share = IncomingShare.new( 1145→ 1145→ id: IncomingShare.generate_id, 1146→ 1146→ local_actor_id: local_recipient.id, 1147→ 1147→ remote_actor_url: actor.id, 1148→ 1148→ remote_actor_acct: actor.acct 1149→ 1149→ ) 1150→ 1150→ ACTOR_STORE.create_incoming_share(share) 1151→ 1151→ puts "Created incoming share from #{actor.acct} to #{local_recipient.username}" 1152→ 1152→ end 1153→ 1153→ else 1154→ 1154→ # Send ActivityPub Invite to remote actor 1155→ 1155→ puts "Sending ActivityPub invite to #{remote_actor.inbox}" 1156→ 1156→ invite_id = "https://#{DOMAIN}/activities/#{Random::Secure.hex(16)}" 1157→ 1157→ invite_activity = JSON.build do |json| 1158→ 1158→ json.object do 1159→ 1159→ json.field "@context", "https://www.w3.org/ns/activitystreams" 1160→ 1160→ json.field "id", invite_id 1161→ 1161→ json.field "type", "Invite" 1162→ 1162→ json.field "actor", actor.id 1163→ 1163→ json.field "object" do 1164→ 1164→ json.object do 1165→ 1165→ json.field "type", "Application" 1166→ 1166→ json.field "name", "josie.place Location Sharing" 1167→ 1167→ end 1168→ 1168→ end 1169→ 1169→ json.field "target", remote_actor_url 1170→ 1170→ json.field "to", [remote_actor_url] 1171→ 1171→ end 1172→ 1172→ end 1173→ 1173→ 1174→ 1174→ # Queue delivery 1175→ 1175→ DELIVERY_QUEUE.enqueue(remote_actor.inbox, invite_activity, actor) 1176→ 1176→ end 1177→ 1177→ 1178→ 1178→ context.response.status = HTTP::Status::CREATED 1179→ 1179→ context.response.content_type = CT_JSON 1180→ 1180→ context.response.print(JSON.build { |json| member.to_json(json) }) 1181→ 1181→ rescue ex 1182→ 1182→ puts "Add member error: #{ex.message}" 1183→ 1183→ context.response.status = HTTP::Status::BAD_REQUEST 1184→ 1184→ context.response.print(%({"error": "Invalid request"})) 1185→ 1185→ end 1186→ 1186→ else 1187→ 1187→ # Check for /members/:member_id DELETE 1188→ 1188→ if subpath.starts_with?("/members/") && method == "DELETE" 1189→ 1189→ member_id = subpath.sub("/members/", "") 1190→ 1190→ puts "Delete member: list_id=#{list_id}, member_id=#{member_id}" 1191→ 1191→ member = ACTOR_STORE.get_list_member(member_id) 1192→ 1192→ puts "Found member: #{member.inspect}" 1193→ 1193→ if member && member.list_id == list_id 1194→ 1194→ ACTOR_STORE.delete_list_member(member_id) 1195→ 1195→ context.response.status = HTTP::Status::NO_CONTENT 1196→ 1196→ else 1197→ 1197→ puts "Member not found or list_id mismatch" 1198→ 1198→ context.response.status = HTTP::Status::NOT_FOUND 1199→ 1199→ context.response.print(%({"error": "Member not found"})) 1200→ 1200→ end 1201→ 1201→ else 1202→ 1202→ context.response.status = HTTP::Status::NOT_FOUND 1203→ 1203→ context.response.print(%({"error": "Not found"})) 1204→ 1204→ end 1205→ 1205→ end 1206→ 1206→ next 1207→ 1207→ end 1208→ 1208→ 1209→ 1209→ # ============================================ 1210→ 1210→ # Incoming Shares API: /api/shares/incoming/* 1211→ 1211→ # ============================================ 1212→ 1212→ if path.starts_with?("/api/shares/incoming") 1213→ 1213→ auth = check_auth(context) 1214→ 1214→ unless auth.authenticated && auth.actor 1215→ 1215→ context.response.status = HTTP::Status::UNAUTHORIZED 1216→ 1216→ context.response.print(%({"error": "Unauthorized"})) 1217→ 1217→ next 1218→ 1218→ end 1219→ 1219→ 1220→ 1220→ actor = auth.actor.not_nil! 1221→ 1221→ 1222→ 1222→ case {path, method} 1223→ 1223→ # Get all incoming shares 1224→ 1224→ when {"/api/shares/incoming", "GET"} 1225→ 1225→ shares = ACTOR_STORE.get_incoming_shares_for_actor(actor.id) 1226→ 1226→ context.response.content_type = CT_JSON 1227→ 1227→ context.response.print(JSON.build do |json| 1228→ 1228→ json.array do 1229→ 1229→ shares.each { |s| s.to_json(json) } 1230→ 1230→ end 1231→ 1231→ end) 1232→ 1232→ else 1233→ 1233→ # Handle /api/shares/incoming/:id/accept or /decline 1234→ 1234→ if path =~ /^\/api\/shares\/incoming\/([^\/]+)\/(accept|decline)$/ && method == "POST" 1235→ 1235→ share_id = $1 1236→ 1236→ action = $2 1237→ 1237→ 1238→ 1238→ share = ACTOR_STORE.get_incoming_share(share_id) 1239→ 1239→ unless share && share.local_actor_id == actor.id 1240→ 1240→ context.response.status = HTTP::Status::NOT_FOUND 1241→ 1241→ context.response.print(%({"error": "Share not found"})) 1242→ 1242→ next 1243→ 1243→ end 1244→ 1244→ 1245→ 1245→ new_status = action == "accept" ? "accepted" : "declined" 1246→ 1246→ accepted_at = action == "accept" ? Time.utc : nil 1247→ 1247→ ACTOR_STORE.update_incoming_share_status(share_id, new_status, accepted_at) 1248→ 1248→ 1249→ 1249→ context.response.content_type = CT_JSON 1250→ 1250→ context.response.print(%({"status": "#{new_status}"})) 1251→ 1251→ else 1252→ 1252→ context.response.status = HTTP::Status::NOT_FOUND 1253→ 1253→ context.response.print(%({"error": "Not found"})) 1254→ 1254→ end 1255→ 1255→ end 1256→ 1256→ next 1257→ 1257→ end 1258→ 1258→ 1259→ 1259→ # ============================================ 1260→ 1260→ # Friend Locations API: /api/friends/locations 1261→ 1261→ # ============================================ 1262→ 1262→ if path == "/api/friends/locations" && method == "GET" 1263→ 1263→ auth = check_auth(context) 1264→ 1264→ unless auth.authenticated && auth.actor 1265→ 1265→ context.response.status = HTTP::Status::UNAUTHORIZED 1266→ 1266→ context.response.print(%({"error": "Unauthorized"})) 1267→ 1267→ next 1268→ 1268→ end 1269→ 1269→ 1270→ 1270→ actor = auth.actor.not_nil! 1271→ 1271→ locations = FRIEND_LOCATIONS.get_all(actor.id) 1272→ 1272→ 1273→ 1273→ context.response.content_type = CT_JSON 1274→ 1274→ context.response.print(JSON.build do |json| 1275→ 1275→ json.array do 1276→ 1276→ locations.each { |loc| loc.to_json(json) } 1277→ 1277→ end 1278→ 1278→ end) 1279→ 1279→ next 1280→ 1280→ end 1281→ 1281→ 1282→ 1282→ # ============================================ 1283→ 1283→ # Actor endpoints: /users/:username/* 1284→ 1284→ # ============================================ 1285→ 1285→ if username = extract_username(path) 1286→ 1286→ actor = ACTOR_STORE.find_by_username(username) 1287→ 1287→ 1288→ 1288→ unless actor 1289→ 1289→ context.response.status = HTTP::Status::NOT_FOUND 1290→ 1290→ context.response.content_type = CT_JSON 1291→ 1291→ context.response.print(%({"error": "Actor not found"})) 1292→ 1292→ next 1293→ 1293→ end 1294→ 1294→ 1295→ 1295→ subpath = path.sub("/users/#{username}", "") 1296→ 1296→ 1297→ 1297→ case {subpath, method} 1298→ 1298→ # Actor profile 1299→ 1299→ when {"", "GET"}, {"/", "GET"} 1300→ 1300→ if wants_activitypub?(context) 1301→ 1301→ context.response.content_type = CT_ACTIVITYPUB 1302→ 1302→ context.response.print(actor.to_activitypub) 1303→ 1303→ else 1304→ 1304→ context.response.content_type = CT_JSON 1305→ 1305→ context.response.print(actor.to_json) 1306→ 1306→ end 1307→ 1307→ # Inbox 1308→ 1308→ when {"/inbox", "POST"} 1309→ 1309→ remote_actor = check_http_signature(context) 1310→ 1310→ unless remote_actor 1311→ 1311→ context.response.status = HTTP::Status::UNAUTHORIZED 1312→ 1312→ context.response.print(%({"error": "Invalid signature"})) 1313→ 1313→ next 1314→ 1314→ end 1315→ 1315→ 1316→ 1316→ body = context.request.body.try(&.gets_to_end) || "" 1317→ 1317→ data = JSON.parse(body) 1318→ 1318→ activity = Activity.from_json_any(data) 1319→ 1319→ 1320→ 1320→ unless activity 1321→ 1321→ context.response.status = HTTP::Status::BAD_REQUEST 1322→ 1322→ context.response.print(%({"error": "Invalid activity"})) 1323→ 1323→ next 1324→ 1324→ end 1325→ 1325→ 1326→ 1326→ case activity.type 1327→ 1327→ when "Follow" 1328→ 1328→ target_id = activity.object_id 1329→ 1329→ if target_id == actor.id 1330→ 1330→ # Someone wants to follow this local actor 1331→ 1331→ follow = Follow.new( 1332→ 1332→ id: activity.id, 1333→ 1333→ actor_id: activity.actor, 1334→ 1334→ target_id: actor.id 1335→ 1335→ ) 1336→ 1336→ 1337→ 1337→ existing = FOLLOW_STORE.find_by_actor_and_target(activity.actor, actor.id) 1338→ 1338→ if existing 1339→ 1339→ FOLLOW_STORE.update_state(existing.id, FollowState::Accepted) 1340→ 1340→ else 1341→ 1341→ FOLLOW_STORE.create(follow) 1342→ 1342→ FOLLOW_STORE.update_state(follow.id, FollowState::Accepted) 1343→ 1343→ end 1344→ 1344→ 1345→ 1345→ # Auto-accept: send Accept activity 1346→ 1346→ accept_activity = ActivityBuilder.create_accept(actor.id, data) 1347→ 1347→ DELIVERY_QUEUE.enqueue(remote_actor.inbox, accept_activity, actor) 1348→ 1348→ end 1349→ 1349→ when "Undo" 1350→ 1350→ inner = activity.object 1351→ 1351→ if inner.as_h? && inner["type"]?.try(&.as_s?) == "Follow" 1352→ 1352→ follower_id = inner["actor"]?.try(&.as_s?) 1353→ 1353→ if follower_id 1354→ 1354→ FOLLOW_STORE.delete_by_actor_and_target(follower_id, actor.id) 1355→ 1355→ end 1356→ 1356→ end 1357→ 1357→ when "Accept" 1358→ 1358→ # Our follow request was accepted 1359→ 1359→ inner = activity.object 1360→ 1360→ if inner.as_h? && inner["type"]?.try(&.as_s?) == "Follow" 1361→ 1361→ follow_id = inner["id"]?.try(&.as_s?) 1362→ 1362→ if follow_id 1363→ 1363→ FOLLOW_STORE.update_state(follow_id, FollowState::Accepted) 1364→ 1364→ end 1365→ 1365→ end 1366→ 1366→ when "Reject" 1367→ 1367→ inner = activity.object 1368→ 1368→ if inner.as_h? && inner["type"]?.try(&.as_s?) == "Follow" 1369→ 1369→ follow_id = inner["id"]?.try(&.as_s?) 1370→ 1370→ if follow_id 1371→ 1371→ FOLLOW_STORE.update_state(follow_id, FollowState::Rejected) 1372→ 1372→ end 1373→ 1373→ end 1374→ 1374→ when "Invite" 1375→ 1375→ # Someone wants to share their location with us 1376→ 1376→ obj = activity.object 1377→ 1377→ if obj.as_h? && obj["type"]?.try(&.as_s?) == "Application" && obj["name"]?.try(&.as_s?).try(&.includes?("Location Sharing")) 1378→ 1378→ # This is a josie.place location sharing invite 1379→ 1379→ remote_actor_url = activity.actor 1380→ 1380→ remote_acct = "#{remote_actor.username}@#{remote_actor.domain}" 1381→ 1381→ 1382→ 1382→ # Check if we already have this share request 1383→ 1383→ existing = ACTOR_STORE.find_incoming_share_by_actor(actor.id, remote_actor_url) 1384→ 1384→ unless existing 1385→ 1385→ share = IncomingShare.new( 1386→ 1386→ id: IncomingShare.generate_id, 1387→ 1387→ local_actor_id: actor.id, 1388→ 1388→ remote_actor_url: remote_actor_url, 1389→ 1389→ remote_actor_acct: remote_acct 1390→ 1390→ ) 1391→ 1391→ ACTOR_STORE.create_incoming_share(share) 1392→ 1392→ puts "Received location sharing invite from #{remote_acct} to #{actor.username}" 1393→ 1393→ end 1394→ 1394→ end 1395→ 1395→ when "Update" 1396→ 1396→ # Location update from a friend 1397→ 1397→ obj = activity.object 1398→ 1398→ if obj.as_h? && obj["type"]?.try(&.as_s?) == "Place" 1399→ 1399→ remote_actor_url = activity.actor 1400→ 1400→ remote_acct = "#{remote_actor.username}@#{remote_actor.domain}" 1401→ 1401→ 1402→ 1402→ # Verify we have a symmetric relationship with this sender 1403→ 1403→ if ACTOR_STORE.can_share_with?(actor.id, remote_actor_url) 1404→ 1404→ lat = obj["latitude"]?.try(&.as_f?) 1405→ 1405→ lng = obj["longitude"]?.try(&.as_f?) 1406→ 1406→ 1407→ 1407→ if lat && lng 1408→ 1408→ friend_loc = FriendLocation.new( 1409→ 1409→ remote_actor_url: remote_actor_url, 1410→ 1410→ remote_actor_acct: remote_acct, 1411→ 1411→ lat: lat, 1412→ 1412→ lng: lng, 1413→ 1413→ accuracy: obj["accuracy"]?.try(&.as_f?), 1414→ 1414→ speed: obj["speed"]?.try(&.as_f?), 1415→ 1415→ heading: obj["heading"]?.try(&.as_f?), 1416→ 1416→ updated_at: Time.utc 1417→ 1417→ ) 1418→ 1418→ FRIEND_LOCATIONS.update(actor.id, friend_loc) 1419→ 1419→ puts "Updated location for #{remote_acct} -> #{actor.username}: #{lat}, #{lng}" 1420→ 1420→ end 1421→ 1421→ else 1422→ 1422→ puts "Rejected location update from #{remote_acct} - no symmetric relationship" 1423→ 1423→ end 1424→ 1424→ end 1425→ 1425→ end 1426→ 1426→ 1427→ 1427→ context.response.status = HTTP::Status::ACCEPTED 1428→ 1428→ context.response.print(%({"ok": true})) 1429→ 1429→ 1430→ 1430→ # Outbox 1431→ 1431→ when {"/outbox", "GET"} 1432→ 1432→ context.response.content_type = CT_ACTIVITYPUB 1433→ 1433→ context.response.print(JSON.build do |json| 1434→ 1434→ json.object do 1435→ 1435→ json.field "@context", "https://www.w3.org/ns/activitystreams" 1436→ 1436→ json.field "type", "OrderedCollection" 1437→ 1437→ json.field "id", actor.outbox 1438→ 1438→ json.field "totalItems", 0 1439→ 1439→ json.field "orderedItems", [] of String 1440→ 1440→ end 1441→ 1441→ end) 1442→ 1442→ 1443→ 1443→ # Followers collection 1444→ 1444→ when {"/followers", "GET"} 1445→ 1445→ followers = FOLLOW_STORE.followers_of(actor.id) 1446→ 1446→ context.response.content_type = CT_ACTIVITYPUB 1447→ 1447→ context.response.print(JSON.build do |json| 1448→ 1448→ json.object do 1449→ 1449→ json.field "@context", "https://www.w3.org/ns/activitystreams" 1450→ 1450→ json.field "type", "OrderedCollection" 1451→ 1451→ json.field "id", actor.followers_url 1452→ 1452→ json.field "totalItems", followers.size 1453→ 1453→ json.field "orderedItems", followers.map(&.actor_id) 1454→ 1454→ end 1455→ 1455→ end) 1456→ 1456→ 1457→ 1457→ # Following collection 1458→ 1458→ when {"/following", "GET"} 1459→ 1459→ following = FOLLOW_STORE.following_of(actor.id) 1460→ 1460→ context.response.content_type = CT_ACTIVITYPUB 1461→ 1461→ context.response.print(JSON.build do |json| 1462→ 1462→ json.object do 1463→ 1463→ json.field "@context", "https://www.w3.org/ns/activitystreams" 1464→ 1464→ json.field "type", "OrderedCollection" 1465→ 1465→ json.field "id", actor.following_url 1466→ 1466→ json.field "totalItems", following.size 1467→ 1467→ json.field "orderedItems", following.map(&.target_id) 1468→ 1468→ end 1469→ 1469→ end) 1470→ 1470→ else 1471→ 1471→ context.response.status = HTTP::Status::NOT_FOUND 1472→ 1472→ context.response.print(%({"error": "Not found"})) 1473→ 1473→ end 1474→ 1474→ 1475→ 1475→ # ============================================ 1476→ 1476→ # Session endpoints: /api/session/:id/* 1477→ 1477→ # ============================================ 1478→ 1478→ elsif session_id = extract_session_id(path) 1479→ 1479→ session = SESSIONS.get(session_id) 1480→ 1480→ 1481→ 1481→ unless session 1482→ 1482→ context.response.status = HTTP::Status::NOT_FOUND 1483→ 1483→ context.response.content_type = CT_JSON 1484→ 1484→ context.response.print(%({"error": "Session not found"})) 1485→ 1485→ next 1486→ 1486→ end 1487→ 1487→ 1488→ 1488→ subpath = path.sub("/api/session/#{session_id}", "") 1489→ 1489→ 1490→ 1490→ case {subpath, method} 1491→ 1491→ # Get current location 1492→ 1492→ when {"", "GET"}, {"/", "GET"} 1493→ 1493→ context.response.content_type = CT_JSON 1494→ 1494→ if loc = session.get 1495→ 1495→ context.response.print(loc.to_json) 1496→ 1496→ else 1497→ 1497→ context.response.print(%({"active": false})) 1498→ 1498→ end 1499→ 1499→ 1500→ 1500→ # Update location 1501→ 1501→ when {"", "POST"}, {"/", "POST"} 1502→ 1502→ auth = check_auth(context) 1503→ 1503→ unless auth.authenticated 1504→ 1504→ context.response.status = HTTP::Status::UNAUTHORIZED 1505→ 1505→ context.response.print(%({"error": "Unauthorized"})) 1506→ 1506→ next 1507→ 1507→ end 1508→ 1508→ 1509→ 1509→ # Check owner match if session has an owner 1510→ 1510→ if session.owner_id && auth.actor 1511→ 1511→ unless session.owner_id == auth.actor.not_nil!.id 1512→ 1512→ context.response.status = HTTP::Status::FORBIDDEN 1513→ 1513→ context.response.print(%({"error": "Not session owner"})) 1514→ 1514→ next 1515→ 1515→ end 1516→ 1516→ end 1517→ 1517→ 1518→ 1518→ begin 1519→ 1519→ body = context.request.body.try(&.gets_to_end) || "" 1520→ 1520→ data = JSON.parse(body) 1521→ 1521→ 1522→ 1522→ loc = Location.new( 1523→ 1523→ lat: data["lat"].as_f, 1524→ 1524→ lng: data["lng"].as_f, 1525→ 1525→ accuracy: data["accuracy"].as_f, 1526→ 1526→ speed: data["speed"]?.try(&.as_f?), 1527→ 1527→ heading: data["heading"]?.try(&.as_f?), 1528→ 1528→ timestamp: Time.utc, 1529→ 1529→ active: true, 1530→ 1530→ expires: Time.utc + 30.minutes 1531→ 1531→ ) 1532→ 1532→ session.update(loc) 1533→ 1533→ 1534→ 1534→ context.response.content_type = CT_JSON 1535→ 1535→ context.response.print(%({"ok": true})) 1536→ 1536→ rescue ex 1537→ 1537→ context.response.status = HTTP::Status::BAD_REQUEST 1538→ 1538→ context.response.print(%({"error": "Invalid JSON"})) 1539→ 1539→ end 1540→ 1540→ 1541→ 1541→ # Stop sharing 1542→ 1542→ when {"/stop", "POST"} 1543→ 1543→ auth = check_auth(context) 1544→ 1544→ unless auth.authenticated 1545→ 1545→ context.response.status = HTTP::Status::UNAUTHORIZED 1546→ 1546→ context.response.print(%({"error": "Unauthorized"})) 1547→ 1547→ next 1548→ 1548→ end 1549→ 1549→ 1550→ 1550→ session.set_inactive 1551→ 1551→ context.response.content_type = CT_JSON 1552→ 1552→ context.response.print(%({"ok": true})) 1553→ 1553→ 1554→ 1554→ # Delete session 1555→ 1555→ when {"/delete", "POST"} 1556→ 1556→ auth = check_auth(context) 1557→ 1557→ unless auth.authenticated 1558→ 1558→ context.response.status = HTTP::Status::UNAUTHORIZED 1559→ 1559→ context.response.print(%({"error": "Unauthorized"})) 1560→ 1560→ next 1561→ 1561→ end 1562→ 1562→ 1563→ 1563→ SESSIONS.delete(session_id) 1564→ 1564→ context.response.content_type = CT_JSON 1565→ 1565→ context.response.print(%({"ok": true})) 1566→ 1566→ 1567→ 1567→ # SSE stream 1568→ 1568→ when {"/stream", "GET"} 1569→ 1569→ context.response.content_type = "text/event-stream" 1570→ 1570→ context.response.headers["Cache-Control"] = "no-cache" 1571→ 1571→ context.response.headers["Connection"] = "keep-alive" 1572→ 1572→ 1573→ 1573→ ch = session.subscribe 1574→ 1574→ 1575→ 1575→ if loc = session.get 1576→ 1576→ context.response.print("data: #{loc.to_json}\n\n") 1577→ 1577→ context.response.flush 1578→ 1578→ end 1579→ 1579→ 1580→ 1580→ spawn do 1581→ 1581→ loop do 1582→ 1582→ sleep 30.seconds 1583→ 1583→ begin 1584→ 1584→ context.response.print(": keepalive\n\n") 1585→ 1585→ context.response.flush 1586→ 1586→ rescue 1587→ 1587→ break 1588→ 1588→ end 1589→ 1589→ end 1590→ 1590→ end 1591→ 1591→ 1592→ 1592→ loop do 1593→ 1593→ select 1594→ 1594→ when loc = ch.receive? 1595→ 1595→ if loc 1596→ 1596→ context.response.print("data: #{loc.to_json}\n\n") 1597→ 1597→ context.response.flush 1598→ 1598→ else 1599→ 1599→ break 1600→ 1600→ end 1601→ 1601→ end 1602→ 1602→ rescue 1603→ 1603→ break 1604→ 1604→ end 1605→ 1605→ 1606→ 1606→ session.unsubscribe(ch) 1607→ 1607→ 1608→ 1608→ # Subscribe to session (federation) 1609→ 1609→ when {"/subscribe", "POST"} 1610→ 1610→ remote_actor = check_http_signature(context) 1611→ 1611→ unless remote_actor 1612→ 1612→ context.response.status = HTTP::Status::UNAUTHORIZED 1613→ 1613→ context.response.print(%({"error": "Invalid signature"})) 1614→ 1614→ next 1615→ 1615→ end 1616→ 1616→ 1617→ 1617→ # Check visibility permissions 1618→ 1618→ if session.visibility == SessionVisibility::FollowersOnly 1619→ 1619→ if owner_id = session.owner_id 1620→ 1620→ unless FOLLOW_STORE.is_following?(remote_actor.id, owner_id) 1621→ 1621→ context.response.status = HTTP::Status::FORBIDDEN 1622→ 1622→ context.response.print(%({"error": "Must be a follower"})) 1623→ 1623→ next 1624→ 1624→ end 1625→ 1625→ end 1626→ 1626→ end 1627→ 1627→ 1628→ 1628→ session.add_remote_subscriber(remote_actor.inbox) 1629→ 1629→ context.response.status = HTTP::Status::ACCEPTED 1630→ 1630→ context.response.print(%({"ok": true})) 1631→ 1631→ else 1632→ 1632→ context.response.status = HTTP::Status::NOT_FOUND 1633→ 1633→ context.response.print(%({"error": "Not found"})) 1634→ 1634→ end 1635→ 1635→ else 1636→ 1636→ context.response.status = HTTP::Status::NOT_FOUND 1637→ 1637→ context.response.print(%({"error": "Not found"})) 1638→ 1638→ end 1639→ 1639→ end 1640→ 1640→end 1641→ 1641→ 1642→ 1642→port = (ENV["PORT"]? || "8081").to_i 1643→ 1643→puts "Location relay server starting on :#{port}" 1644→ 1644→puts "Domain: #{DOMAIN}" 1645→ 1645→puts "Invite codes: #{INVITE_CODES.size} configured" 1646→ 1646→server.bind_tcp("0.0.0.0", port) 1647→ 1647→server.listen 1648→ 1648→