Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,8 @@ A KOReader plugin to update your [Hardcover.app](https://hardcover.app) reading
## Installation

1. Download and extract the latest release: https://github.com/Billiam/hardcoverapp.koplugin/releases/latest
2. Rename `hardcover_config.example.lua` to `hardcover_config.lua`
3. Fetch your API key from https://hardcover.app/account/api (just the part after `Bearer `)
4. Add your API key to the `token` field in `hardcover_config.lua`, between the `''` quotes. For example:
`token = 'abcde...fghij'`
5. Install plugin by copying the `hardcoverapp.koplugin` folder to the KOReader plugins folder on your device
2. Install plugin by copying the `hardcoverapp.koplugin` folder to the KOReader plugins folder on your device
3. Open up the Hardcover item in the Bookmark menu (the bookmark icon in reader view, cabinet in book list), click sign in, and follow the instructions

## Usage

Expand Down
4 changes: 0 additions & 4 deletions hardcover/lib/constants/hardcover.lua
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,6 @@ local Hardcover = {
CATEGORY = {
TAG = "Tag",
},
ERROR = {
JWT = "invalid-jwt",
TOKEN = "Unable to verify token",
}
}

return Hardcover
8 changes: 8 additions & 0 deletions hardcover/lib/constants/oauth.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
return {
API_BASE = "https://api.hardcover.app",
DEVICE_ENDPOINT = "/oauth2/device",
TOKEN_ENDPOINT = "/oauth2/token",
REVOKE_ENDPOINT = "/oauth2/revoke",
CLIENT_ID = "", -- TODO: put your client id here
SCOPES = "read:me:content read:catalog read:library write:library",
}
6 changes: 6 additions & 0 deletions hardcover/lib/constants/settings.lua
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ local Settings = {
PROGRESS = "progress",
},
USER_ID = "user_id",
NAME = "name",
USERNAME = "username",

ACCESS_TOKEN = "access_token",
REFRESH_TOKEN = "refresh_token",
TOKEN_EXPIRES_AT = "token_expires_at",
}

Settings.AUTOLINK_OPTIONS = { Settings.LINK_BY_HARDCOVER, Settings.LINK_BY_ISBN, Settings.LINK_BY_TITLE }
Expand Down
132 changes: 113 additions & 19 deletions hardcover/lib/hardcover_api.lua
Original file line number Diff line number Diff line change
@@ -1,30 +1,38 @@
local config = require("hardcover_config")
local _ = require("gettext")
local json = require("json")
local logger = require("logger")
local http = require("socket.http")
local ltn12 = require("ltn12")
local json = require("json")
local _t = require("hardcover/lib/table_util")
local T = require("ffi/util").template
local Trapper = require("ui/trapper")
local NetworkManager = require("ui/network/manager")
local os = require("os")
local socketutil = require("socketutil")
local http = require("socket.http")

local NetworkManager = require("ui/network/manager")
local Trapper = require("ui/trapper")
local UIManager = require("ui/uimanager")
local InfoMessage = require("ui/widget/infomessage")

local _t = require("hardcover/lib/table_util")

local Book = require("hardcover/lib/book")
local VERSION = require("hardcover_version")
local SETTING = require("hardcover/lib/constants/settings")
local OAuthClient = require("hardcover/lib/oauth_client")
local user_agent = require("hardcover/lib/user_agent")

local api_url = "https://api.hardcover.app/v1/graphql"

local headers = {
["Content-Type"] = "application/json",
["User-Agent"] = T("hardcoverapp.koplugin/%1 (https://github.com/billiam/hardcoverapp.koplugin)",
table.concat(VERSION, ".")),
Authorization = "Bearer " .. config.token
}

local HardcoverApi = {
enabled = true
}

function HardcoverApi:_headers()
local token = self.settings and self.settings:readSetting(SETTING.ACCESS_TOKEN)
return {
["Content-Type"] = "application/json",
["User-Agent"] = user_agent,
Authorization = token and ("Bearer " .. token) or nil,
}
end

local book_fragment = [[
fragment BookParts on books {
book_id: id
Expand Down Expand Up @@ -88,6 +96,8 @@ function HardcoverApi:me()
local result = self:query([[{
me {
id
name
username
account_privacy_setting_id
}
}]])
Expand All @@ -98,21 +108,105 @@ function HardcoverApi:me()
return {}
end

local function isAuthError(code, data)
return code == "401" or (data and (data.error == "invalid_token" or data.error == "invalid_grant"))
end

function HardcoverApi:refreshToken()
if not self.settings then
return false, "no_settings"
end

local refresh_token = self.settings:readSetting(SETTING.REFRESH_TOKEN)
if not refresh_token then
return false, "no_refresh_token"
end

local tokens, err, data = OAuthClient:refresh(refresh_token)
if tokens and tokens.access_token then
self.settings:updateSetting(SETTING.ACCESS_TOKEN, tokens.access_token)
if tokens.refresh_token then
self.settings:updateSetting(SETTING.REFRESH_TOKEN, tokens.refresh_token)
end
self.settings:updateSetting(SETTING.TOKEN_EXPIRES_AT, os.time() + (tonumber(tokens.expires_in) or 0))
return true
elseif err == "rejected" then
logger.warn("Hardcover refresh token rejected, forgetting signed in info")
self:handleAuthExpired()
return false, "rejected"
else
logger.warn("Hardcover token refresh failed:", err)
return false, err
end
end

function HardcoverApi:handleAuthExpired()
local had_token = self.settings and (self.settings:readSetting(SETTING.ACCESS_TOKEN) or self.settings:readSetting(SETTING.REFRESH_TOKEN))
if self.settings then
self.settings:clearAuth()
end
self.enabled = false

if had_token then
if self.on_auth_expired then
self.on_auth_expired()
else
UIManager:show(InfoMessage:new {
text = _("Your Hardcover sign-in has expired. Please sign in again from the Hardcover menu"),
icon = "notice-warning",
})
end
end
end

function HardcoverApi:query(query, parameters)
if not NetworkManager:isConnected() or not self.enabled then
return
end

local completed, success, content
if self.settings and self.settings:readSetting(SETTING.REFRESH_TOKEN) then
if self.settings:isTokenExpired() then
local ok, reason = self:refreshToken()
if not ok and reason == "rejected" then
return nil, { error = "auth_expired" }
end
end
end

completed, content = Trapper:dismissableRunInSubprocess(function()
local completed, content = Trapper:dismissableRunInSubprocess(function()
return self:_query(query, parameters)
end, true, true)

if completed and content then
local code, response = string.match(content, "^([^:]*):(.*)")
if string.find(code, "^%d%d%d") then
local data = json.decode(response, json.decode.simple)
local ok, data = pcall(json.decode, response, json.decode.simple)
data = (ok and type(data) == "table") and data or {}

if isAuthError(code, data) then
if self.settings and self.settings:readSetting(SETTING.REFRESH_TOKEN) then
local refreshed, reason = self:refreshToken()
if refreshed then
completed, content = Trapper:dismissableRunInSubprocess(function()
return self:_query(query, parameters)
end, true, true)

if completed and content then
code, response = string.match(content, "^([^:]*):(.*)")
if string.find(code, "^%d%d%d") then
ok, data = pcall(json.decode, response, json.decode.simple)
data = (ok and type(data) == "table") and data or {}
end
end
elseif reason == "rejected" then
return nil, { error = "auth_expired" }
end
else
self:handleAuthExpired()
return nil, { error = "auth_expired" }
end
end

if data.data then
return data.data
elseif data.errors or data.error then
Expand Down Expand Up @@ -147,7 +241,7 @@ function HardcoverApi:_query(query, parameters)
local request = {
url = api_url,
method = "POST",
headers = headers,
headers = self:_headers(),
source = ltn12.source.string(json.encode(requestBody)),
sink = socketutil.table_sink(sink),
}
Expand Down
23 changes: 23 additions & 0 deletions hardcover/lib/hardcover_settings.lua
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
local KoreaderVersion = require("version")
local LuaSettings = require("luasettings")
local os = require("os")

local _t = require("hardcover/lib/table_util")
local SETTING = require("hardcover/lib/constants/settings")
Expand Down Expand Up @@ -202,4 +203,26 @@ function HardcoverSettings:menuConfirm()
return self.settings:readSetting(SETTING.MENU_CONFIRMATION) == true
end

function HardcoverSettings:signedIn()
return self.settings:readSetting(SETTING.ACCESS_TOKEN) ~= nil
end

function HardcoverSettings:isTokenExpired(leeway)
leeway = leeway or 60
local expires_at = self.settings:readSetting(SETTING.TOKEN_EXPIRES_AT)
if not expires_at then
return false
end
return os.time() >= (tonumber(expires_at) - leeway)
end

function HardcoverSettings:clearAuth()
self:updateSetting(SETTING.ACCESS_TOKEN, nil)
self:updateSetting(SETTING.REFRESH_TOKEN, nil)
self:updateSetting(SETTING.TOKEN_EXPIRES_AT, nil)
self:updateSetting(SETTING.USER_ID, nil)
self:updateSetting(SETTING.NAME, nil)
self:updateSetting(SETTING.USERNAME, nil)
end

return HardcoverSettings
98 changes: 98 additions & 0 deletions hardcover/lib/oauth_client.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
local logger = require("logger")
local http = require("socket.http")
local ltn12 = require("ltn12")
local url = require("socket.url")
local socketutil = require("socketutil")

local json = require("json")
local Trapper = require("ui/trapper")

local user_agent = require("hardcover/lib/user_agent")
local OAUTH = require("hardcover/lib/constants/oauth")

local OAuthClient = {}

local function form_encode(fields)
local parts = {}
for k, v in pairs(fields) do
table.insert(parts, url.escape(k) .. "=" .. url.escape(v))
end
return table.concat(parts, "&")
end

local function post_form(path, fields)
local body = form_encode(fields)
local sink = {}
socketutil:set_timeout(6, 15)
local _, code = http.request({
url = OAUTH.API_BASE .. path,
method = "POST",
headers = {
["Content-Type"] = "application/x-www-form-urlencoded",
["Content-Length"] = tostring(#body),
["User-Agent"] = user_agent,
},
source = ltn12.source.string(body),
sink = socketutil.table_sink(sink),
})
socketutil:reset_timeout()
return tostring(code) .. ":" .. table.concat(sink)
end

-- Each of these blocks on network I/O. Call from `Trapper:dismissableRunInSubprocess`

function OAuthClient:_requestDeviceCode()
return post_form(OAUTH.DEVICE_ENDPOINT, { client_id = OAUTH.CLIENT_ID, scope = OAUTH.SCOPES })
end

function OAuthClient:_pollToken(device_code)
return post_form(OAUTH.TOKEN_ENDPOINT, {
grant_type = "urn:ietf:params:oauth:grant-type:device_code",
device_code = device_code,
client_id = OAUTH.CLIENT_ID,
})
end

function OAuthClient:_refresh(refresh_token)
return post_form(OAUTH.TOKEN_ENDPOINT, {
grant_type = "refresh_token",
refresh_token = refresh_token,
client_id = OAUTH.CLIENT_ID,
})
end

function OAuthClient:refresh(refresh_token)
local completed, raw = Trapper:dismissableRunInSubprocess(function()
return self:_refresh(refresh_token)
end, false, true)

if not completed or not raw then
return nil, "network_error"
end

local code, body = raw:match("^([^:]*):(.*)")
if not (code and code:match("^%d%d%d")) then
return nil, "network_error"
end

local ok, data = pcall(json.decode, body, json.decode.simple)
data = (ok and type(data) == "table") and data or {}

if code:match("^2%d%d") and data.access_token then
return data
elseif code == "400" or code == "401" or data.error == "invalid_grant" or data.error == "invalid_token" or data.error == "expired_token" then
return nil, "rejected", data
else
return nil, "error", data
end
end

function OAuthClient:_revoke(token, token_type_hint)
return post_form(OAUTH.REVOKE_ENDPOINT, {
token = token,
token_type_hint = token_type_hint,
client_id = OAUTH.CLIENT_ID,
})
end

return OAuthClient
Loading