Don't forget to lycansubscribe
1require 'json' 2require 'sinatra/base' 3 4require_relative 'init' 5require_relative 'authenticator' 6require_relative 'models/user' 7require_relative 'query_parser' 8 9class Server < Sinatra::Application 10 register Sinatra::ActiveRecordExtension 11 set :port, 3000 12 13 PAGE_LIMIT = 25 14 HOSTNAME = 'lycan.feeds.blue' 15 16 helpers do 17 def json_response(data) 18 content_type :json 19 JSON.generate(data) 20 end 21 22 def json_error(name, message, status: 400) 23 content_type :json 24 [status, JSON.generate({ error: name, message: message })] 25 end 26 27 def get_user_did 28 if settings.development? 29 if did = params[:user] 30 return did 31 else 32 halt json_error('AuthMissing', 'Missing "user" parameter', status: 401) 33 end 34 else 35 auth_header = env['HTTP_AUTHORIZATION'] 36 if auth_header.to_s.strip.empty? 37 halt json_error('AuthMissing', 'Missing authentication header', status: 401) 38 end 39 40 begin 41 method = request.path.split('/')[2] 42 did = @authenticator.decode_user_from_jwt(auth_header, method) 43 rescue StandardError => e 44 halt json_error('InvalidToken', e.message, status: 401) 45 end 46 47 if did 48 return did 49 else 50 halt json_error('InvalidToken', "Invalid JWT token", status: 401) 51 end 52 end 53 end 54 55 def load_user 56 did = get_user_did 57 58 if user = User.find_by(did: did) 59 return user 60 else 61 halt json_error('AccountNotFound', 'Account not found', status: 401) 62 end 63 end 64 end 65 66 before do 67 @authenticator = Authenticator.new(hostname: HOSTNAME) 68 end 69 70 get '/xrpc/blue.feeds.lycan.searchPosts' do 71 headers['access-control-allow-origin'] = '*' 72 73 @user = load_user 74 75 if params[:query].to_s.strip.empty? 76 return json_error('MissingParameter', 'Missing "query" parameter') 77 end 78 79 if params[:collection].to_s.strip.empty? 80 return json_error('MissingParameter', 'Missing "collection" parameter') 81 end 82 83 query = QueryParser.new(params[:query]) 84 85 collection = case params[:collection] 86 when 'likes' then @user.likes 87 when 'pins' then @user.pins 88 when 'quotes' then @user.quotes 89 when 'reposts' then @user.reposts 90 else return json_error('InvalidParameter', 'Invalid search collection') 91 end 92 93 if query.terms.empty? 94 return json_response(terms: [], posts: [], cursor: nil) 95 end 96 97 items = collection 98 .joins(:post) 99 .includes(:post => :user) 100 .matching_terms(query.terms) 101 .excluding_terms(query.exclusions) 102 .reverse_chronologically 103 .after_cursor(params[:cursor]) 104 .limit(PAGE_LIMIT) 105 106 post_uris = case params[:collection] 107 when 'quotes' 108 items.map { |x| "at://#{@user.did}/app.bsky.feed.post/#{x.rkey}" } 109 else 110 items.map(&:post).map(&:at_uri) 111 end 112 113 json_response(terms: query.terms, posts: post_uris, cursor: items.last&.cursor) 114 end 115 116 post '/xrpc/blue.feeds.lycan.startImport' do 117 headers['access-control-allow-origin'] = '*' 118 119 did = get_user_did 120 user = User.find_or_create_by!(did: did) 121 122 if user.import_job || user.active? 123 json_response(message: "Import has already started") 124 else 125 user.create_import_job! 126 127 status 202 128 json_response(message: "Import has been scheduled") 129 end 130 end 131 132 get '/xrpc/blue.feeds.lycan.getImportStatus' do 133 headers['access-control-allow-origin'] = '*' 134 135 did = get_user_did 136 user = User.find_by(did: did) 137 until_date = user&.imported_until 138 139 case until_date 140 when nil 141 json_response(status: 'not_started') 142 when :end 143 json_response(status: 'finished') 144 else 145 progress = 1 - (until_date - user.registered_at) / (Time.now - user.registered_at) 146 json_response(status: 'in_progress', position: until_date.iso8601, progress: progress) 147 end 148 end 149 150 get '/.well-known/did.json' do 151 json_response({ 152 '@context': ['https://www.w3.org/ns/did/v1'], 153 id: "did:web:#{HOSTNAME}", 154 service: [ 155 { 156 id: '#lycan', 157 type: 'LycanServer', 158 serviceEndpoint: "https://#{HOSTNAME}" 159 } 160 ] 161 }) 162 end 163end