require 'json' require 'sinatra/base' require_relative 'init' require_relative 'authenticator' require_relative 'models/user' require_relative 'query_parser' class Server < Sinatra::Application register Sinatra::ActiveRecordExtension set :port, 3000 PAGE_LIMIT = 25 HOSTNAME = ENV['SERVER_HOSTNAME'] || 'lycan.feeds.blue' helpers do def json_response(data) content_type :json JSON.generate(data) end def json_error(name, message, status: 400) content_type :json [status, JSON.generate({ error: name, message: message })] end def get_user_did if settings.development? did = if request.post? json = JSON.parse(request.body.read) request.body.rewind json['user'] else params[:user] end if did return did else halt json_error('AuthMissing', 'Missing "user" parameter', status: 401) end else auth_header = env['HTTP_AUTHORIZATION'] if auth_header.to_s.strip.empty? halt json_error('AuthMissing', 'Missing authentication header', status: 401) end begin method = request.path.split('/')[2] did = @authenticator.decode_user_from_jwt(auth_header, method) rescue StandardError => e halt json_error('InvalidToken', e.message, status: 401) end if did return did else halt json_error('InvalidToken', "Invalid JWT token", status: 401) end end end def load_user did = get_user_did if user = User.find_by(did: did) return user else halt json_error('AccountNotFound', 'Account not found', status: 401) end end end before do @authenticator = Authenticator.new(hostname: HOSTNAME) if settings.development? headers['Access-Control-Allow-Origin'] = '*' end end get '/' do "Awoo 🐺" end get '/xrpc/blue.feeds.lycan.searchPosts' do @user = load_user if params[:query].to_s.strip.empty? return json_error('MissingParameter', 'Missing "query" parameter') end if params[:collection].to_s.strip.empty? return json_error('MissingParameter', 'Missing "collection" parameter') end query = QueryParser.new(params[:query]) collection = case params[:collection] when 'likes' then @user.likes when 'pins' then @user.pins when 'quotes' then @user.quotes when 'reposts' then @user.reposts else return json_error('InvalidParameter', 'Invalid search collection') end if query.terms.empty? return json_response(terms: [], posts: [], cursor: nil) end items = collection .joins(:post) .includes(:post => :user) .matching_terms(query.terms) .excluding_terms(query.exclusions) .reverse_chronologically .after_cursor(params[:cursor]) .limit(PAGE_LIMIT) post_uris = case params[:collection] when 'quotes' items.map { |x| "at://#{@user.did}/app.bsky.feed.post/#{x.rkey}" } else items.map(&:post).map(&:at_uri) end json_response(terms: query.terms, posts: post_uris, cursor: items.last&.cursor) end if settings.development? options '/xrpc/blue.feeds.lycan.startImport' do headers['Access-Control-Allow-Origin'] = '*' headers['Access-Control-Allow-Headers'] = 'content-type' end end post '/xrpc/blue.feeds.lycan.startImport' do did = get_user_did user = User.find_or_create_by(did: did) if !user.valid? json_error('InvalidRequest', 'Invalid DID') elsif user.import_job || user.active? json_response(message: "Import has already started") else user.create_import_job! status 202 json_response(message: "Import has been scheduled") end end get '/xrpc/blue.feeds.lycan.getImportStatus' do did = get_user_did user = User.find_by(did: did) until_date = user&.imported_until case until_date when nil json_response(status: 'not_started') when :end json_response(status: 'finished') else progress = 1 - (until_date - user.registered_at) / (Time.now - user.registered_at) json_response(status: 'in_progress', position: until_date.iso8601, progress: progress) end end get '/.well-known/did.json' do json_response({ '@context': ['https://www.w3.org/ns/did/v1'], id: "did:web:#{HOSTNAME}", service: [ { id: '#lycan', type: 'LycanServer', serviceEndpoint: "https://#{HOSTNAME}" } ] }) end end