Unofficial Paperbnd/Popfeed plugin for KOReader
1local http = require("socket.http")
2local url = require("socket.url")
3local ltn12 = require("ltn12")
4local socket = require("socket")
5local socketutil = require("socketutil")
6local rapidjson = require("rapidjson")
7
8local XRPCClient = {
9 pds_url = nil,
10 handle = nil,
11 app_password = nil,
12 access_token = nil,
13 refresh_token = nil,
14 timeout = 30,
15 on_credentials_updated = nil, -- Callback for when credentials are refreshed
16}
17
18function XRPCClient:new(o)
19 o = o or {}
20 setmetatable(o, self)
21 self.__index = self
22 return o
23end
24
25function XRPCClient:setPDS(pds_url)
26 self.pds_url = pds_url
27end
28
29function XRPCClient:setAuth(access_token, refresh_token)
30 self.access_token = access_token
31 if refresh_token then
32 self.refresh_token = refresh_token
33 end
34end
35
36function XRPCClient:setCredentials(handle, app_password)
37 self.handle = handle
38 self.app_password = app_password
39end
40
41function XRPCClient:setCredentialsCallback(callback)
42 self.on_credentials_updated = callback
43end
44
45-- Notify that credentials were updated
46function XRPCClient:notifyCredentialsUpdated(access_token, refresh_token)
47 if self.on_credentials_updated then
48 self.on_credentials_updated(access_token, refresh_token)
49 end
50end
51
52function XRPCClient:call(opts)
53 opts = opts or {}
54 local method = opts.method
55 local params = opts.params
56 local body = opts.body
57 local skip_renewal = opts.skip_renewal or false
58 local pds = opts.pds or self.pds_url
59
60 if not pds then
61 return nil, "PDS URL not set"
62 end
63
64 local endpoint = pds .. "/xrpc/" .. method
65
66 -- Add query parameters if provided
67 if params and next(params) then
68 local query_parts = {}
69 for k, v in pairs(params) do
70 table.insert(query_parts, k .. "=" .. url.escape(tostring(v)))
71 end
72 endpoint = endpoint .. "?" .. table.concat(query_parts, "&")
73 end
74
75 local request_body = nil
76 local source = nil
77 local is_post = body ~= nil
78 local headers = {
79 ["Accept"] = "application/json",
80 }
81
82 -- Handle request body (POST only)
83 if is_post then
84 headers["Content-Type"] = "application/json"
85
86 -- Add authorization header for POST requests if token is set
87 if self.access_token then
88 headers["Authorization"] = "Bearer " .. self.access_token
89 end
90
91 local encoded, err = rapidjson.encode(body)
92 if err then
93 return nil, "Failed to encode JSON: " .. err
94 end
95 request_body = encoded
96 headers["Content-Length"] = tostring(#request_body)
97 source = ltn12.source.string(request_body)
98 end
99
100 local sink = {}
101 local request = {
102 url = endpoint,
103 method = is_post and "POST" or "GET",
104 sink = ltn12.sink.table(sink),
105 source = source,
106 headers = headers,
107 }
108
109 socketutil:set_timeout(self.timeout, self.timeout)
110 local code, response_headers = socket.skip(1, http.request(request))
111 socketutil:reset_timeout()
112
113 if not code then
114 return nil, "HTTP request failed: " .. tostring(response_headers)
115 end
116
117 local response_body = table.concat(sink)
118
119 -- Handle authentication errors with automatic session renewal
120 local is_auth_error = code == 401
121 or (code == 400 and response_body and string.find(string.lower(response_body), "expired"))
122
123 if is_auth_error and not skip_renewal then
124 -- Try to renew session (refresh token -> app password fallback)
125 local renewed, renew_err = self:renewSession()
126
127 if renewed then
128 -- Session renewed successfully, retry the original request
129 -- Rebuild request with new authorization header
130 if is_post and self.access_token then
131 headers["Authorization"] = "Bearer " .. self.access_token
132 end
133
134 sink = {}
135 request.sink = ltn12.sink.table(sink)
136 if is_post then
137 request.source = ltn12.source.string(request_body)
138 end
139 request.headers = headers
140
141 socketutil:set_timeout(self.timeout, self.timeout)
142 code, response_headers = socket.skip(1, http.request(request))
143 socketutil:reset_timeout()
144
145 if not code then
146 return nil, "HTTP request failed after session renewal: " .. tostring(response_headers)
147 end
148
149 response_body = table.concat(sink)
150 else
151 -- Could not renew session
152 return nil, "Session expired and renewal failed: " .. (renew_err or "unknown error")
153 end
154 end
155
156 -- Handle non-200 responses
157 if code ~= 200 then
158 local error_msg
159 if response_body and response_body ~= "" then
160 local error_data = rapidjson.decode(response_body)
161 if error_data and error_data.message then
162 error_msg = error_data.message
163 else
164 error_msg = response_body
165 end
166 else
167 error_msg = "HTTP " .. code
168 end
169
170 return nil, error_msg
171 end
172
173 -- Decode response
174 if response_body and response_body ~= "" then
175 local decoded, err = rapidjson.decode(response_body)
176 if err then
177 return nil, "Failed to decode JSON response: " .. err
178 end
179 return decoded, nil
180 end
181
182 return {}, nil
183end
184
185-- Resolve PDS from handle using Slingshot service
186function XRPCClient:resolvePDS(handle)
187 local response, err = self:call({
188 method = "com.bad-example.identity.resolveMiniDoc",
189 params = { identifier = handle },
190 pds = "https://slingshot.microcosm.blue",
191 })
192
193 if err or not response then
194 return nil, "Failed to resolve PDS for handle: " .. err
195 end
196
197 if not response.pds then
198 return nil, "Invalid response from Slingshot service"
199 end
200
201 return response.pds, nil
202end
203
204-- Create session (login)
205function XRPCClient:createSession(identifier, password)
206 local response, err = self:call({
207 method = "com.atproto.server.createSession",
208 body = {
209 identifier = identifier,
210 password = password,
211 },
212 skip_renewal = true, -- Don't auto-renew during initial login
213 })
214
215 if err or not response then
216 return nil, err
217 end
218
219 if response.accessJwt then
220 self:setAuth(response.accessJwt, response.refreshJwt)
221 -- Note: We don't call notifyCredentialsUpdated here since the caller
222 -- of createSession is expected to handle storing credentials
223 end
224
225 return response, nil
226end
227
228-- Validate current session by making a test request
229function XRPCClient:validateSession()
230 if not self.access_token then
231 return false, "No access token"
232 end
233
234 -- Make a lightweight request to check if session is valid
235 local response, err = self:call({
236 method = "com.atproto.server.getSession",
237 body = {},
238 skip_renewal = true, -- Don't auto-renew during validation
239 })
240
241 return response ~= nil, err
242end
243
244-- Refresh session using refresh token
245function XRPCClient:refreshSession()
246 if not self.refresh_token then
247 return nil, "No refresh token available"
248 end
249
250 -- Temporarily store the current access token
251 local old_access_token = self.access_token
252
253 -- Use refresh token for this request
254 self.access_token = self.refresh_token
255
256 local response, err = self:call({
257 method = "com.atproto.server.refreshSession",
258 body = {},
259 skip_renewal = true, -- Don't recurse during refresh
260 })
261
262 if err or not response then
263 -- Restore old access token on failure
264 self.access_token = old_access_token
265 return nil, err
266 end
267
268 -- Update tokens with new values
269 if response.accessJwt then
270 self:setAuth(response.accessJwt, response.refreshJwt)
271 self:notifyCredentialsUpdated(response.accessJwt, response.refreshJwt)
272 end
273
274 return response, nil
275end
276
277-- Renew session with multi-level fallback
278-- 1. Try to use refresh token
279-- 2. If refresh token expired, create new session with app password
280function XRPCClient:renewSession()
281 -- First, try to refresh with refresh token
282 local response, err = self:refreshSession()
283
284 if response then
285 return true, nil
286 end
287
288 -- If refresh failed and we have app password, try to create new session
289 if self.handle and self.app_password then
290 local session, session_err = self:createSession(self.handle, self.app_password)
291
292 if session then
293 -- New session created successfully, tokens already set by createSession
294 self:notifyCredentialsUpdated(session.accessJwt, session.refreshJwt)
295 return true, nil
296 end
297
298 return false, "Failed to refresh session and create new session: " .. (session_err or "unknown error")
299 end
300
301 return false,
302 "Failed to refresh session: " .. (err or "unknown error") .. ", and no app password available for fallback"
303end
304
305-- List records from a collection
306function XRPCClient:listRecords(repo, collection, limit, cursor)
307 local params = {
308 repo = repo,
309 collection = collection,
310 }
311
312 if limit then
313 params.limit = limit
314 end
315
316 if cursor then
317 params.cursor = cursor
318 end
319
320 return self:call({
321 method = "com.atproto.repo.listRecords",
322 params = params,
323 })
324end
325
326-- Get a single record
327function XRPCClient:getRecord(repo, collection, rkey)
328 local params = {
329 repo = repo,
330 collection = collection,
331 rkey = rkey,
332 }
333
334 return self:call({
335 method = "com.atproto.repo.getRecord",
336 params = params,
337 })
338end
339
340-- Put (create or update) a record
341function XRPCClient:putRecord(repo, collection, rkey, record)
342 return self:call({
343 method = "com.atproto.repo.putRecord",
344 body = {
345 repo = repo,
346 collection = collection,
347 rkey = rkey,
348 record = record,
349 },
350 })
351end
352
353return XRPCClient