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