Unofficial Paperbnd/Popfeed plugin for KOReader
at main 8.9 kB view raw
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