fix: harden sync backends and fix edit recompute (#66)
* refactor(oauth): async coroutine support, pure-Lua PKCE, server hardening Problem: OAuth module shelled out to openssl for PKCE, used blocking `vim.system():wait()`, had a weak `os.time()` PRNG seed, and the TCP callback server leaked on read errors with no timeout. Solution: Add `M.system()` coroutine wrapper and `M.async()` helper, replace openssl with `vim.fn.sha256` + `vim.base64.encode`, seed from `vim.uv.hrtime()`, add `close_server()` guard with 120s timeout, and close the server on read errors. * fix(gtasks): async operations, error notifications, buffer refresh Problem: Sync operations blocked the editor, `push_pass` silently dropped delete/update/create API errors, and the buffer was not re-rendered after push/pull/sync. Solution: Wrap `push`, `pull`, `sync` in `oauth.async()`, add `vim.notify` for all `push_pass` failure paths, and re-render the pending buffer after each operation. * fix(init): edit recompute, filter predicates, sync action listing Problem: `M.edit()` skipped `_recompute_counts()` after saving, `compute_hidden_ids` lacked `done`/`pending` predicates, and `run_sync` defaulted to `sync` instead of listing available actions. Solution: Replace `s:save()` with `_save_and_notify()` in `M.edit()`, add `done` and `pending` filter predicates, and list backend actions when no action is specified. * refactor(gcal): per-category calendars, async push, error notifications Problem: gcal used a single hardcoded calendar name, ran synchronously blocking the editor, and silently dropped some API errors. Solution: Fetch all calendars and map categories to calendars (creating on demand), wrap push in `oauth.async()`, notify on individual API failures, track `_gcal_calendar_id` in `_extra`, and remove the `$` anchor from `next_day` pattern. * refactor: formatting fixes, config cleanup, health simplification Problem: Formatter disagreements in `init.lua` and `gtasks.lua`, stale `calendar` field in gcal config, and redundant health checks for data directory existence. Solution: Apply stylua formatting, remove `calendar` field from `pending.GcalConfig`, drop data-dir and no-file health messages, add `done`/`pending` to filter tab-completion candidates. * docs: update vimdoc for sync refactor, remove demo scripts Problem: Docs still referenced openssl dependency, defaulting to `sync` action, and the `calendar` config field. Demo scripts used the old singleton `store` API. Solution: Update vimdoc and README to reflect explicit actions, per- category calendars, and pure-Lua PKCE. Remove stale demo scripts and update sync specs to match new behavior. * fix(types): correct LuaLS annotations in oauth and gcal
This commit is contained in:
parent
6910bdb1be
commit
ee362f7785
13 changed files with 319 additions and 291 deletions
|
|
@ -27,6 +27,27 @@ OAuthClient.__index = OAuthClient
|
|||
---@class pending.oauth
|
||||
local M = {}
|
||||
|
||||
---@param args string[]
|
||||
---@param opts? table
|
||||
---@return { code: integer, stdout: string, stderr: string }
|
||||
function M.system(args, opts)
|
||||
local co = coroutine.running()
|
||||
if not co then
|
||||
return vim.system(args, opts or {}):wait() --[[@as { code: integer, stdout: string, stderr: string }]]
|
||||
end
|
||||
vim.system(args, opts or {}, function(result)
|
||||
vim.schedule(function()
|
||||
coroutine.resume(co, result)
|
||||
end)
|
||||
end)
|
||||
return coroutine.yield() --[[@as { code: integer, stdout: string, stderr: string }]]
|
||||
end
|
||||
|
||||
---@param fn fun(): nil
|
||||
function M.async(fn)
|
||||
coroutine.resume(coroutine.create(fn))
|
||||
end
|
||||
|
||||
---@param str string
|
||||
---@return string
|
||||
function M.url_encode(str)
|
||||
|
|
@ -91,7 +112,7 @@ function M.curl_request(method, url, headers, body)
|
|||
table.insert(args, body)
|
||||
end
|
||||
table.insert(args, url)
|
||||
local result = vim.system(args, { text = true }):wait()
|
||||
local result = M.system(args, { text = true })
|
||||
if result.code ~= 0 then
|
||||
return nil, 'curl failed: ' .. (result.stderr or '')
|
||||
end
|
||||
|
|
@ -125,11 +146,6 @@ function M.health(backend_name)
|
|||
else
|
||||
vim.health.warn('curl not found (needed for ' .. backend_name .. ' sync)')
|
||||
end
|
||||
if vim.fn.executable('openssl') == 1 then
|
||||
vim.health.ok('openssl found (required for ' .. backend_name .. ' OAuth PKCE)')
|
||||
else
|
||||
vim.health.warn('openssl not found (needed for ' .. backend_name .. ' OAuth)')
|
||||
end
|
||||
end
|
||||
|
||||
---@return string
|
||||
|
|
@ -189,19 +205,17 @@ function OAuthClient:refresh_access_token(creds, tokens)
|
|||
.. '&grant_type=refresh_token'
|
||||
.. '&refresh_token='
|
||||
.. M.url_encode(tokens.refresh_token)
|
||||
local result = vim
|
||||
.system({
|
||||
'curl',
|
||||
'-s',
|
||||
'-X',
|
||||
'POST',
|
||||
'-H',
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
'-d',
|
||||
body,
|
||||
TOKEN_URL,
|
||||
}, { text = true })
|
||||
:wait()
|
||||
local result = M.system({
|
||||
'curl',
|
||||
'-s',
|
||||
'-X',
|
||||
'POST',
|
||||
'-H',
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
'-d',
|
||||
body,
|
||||
TOKEN_URL,
|
||||
}, { text = true })
|
||||
if result.code ~= 0 then
|
||||
return nil
|
||||
end
|
||||
|
|
@ -247,23 +261,18 @@ function OAuthClient:auth()
|
|||
|
||||
local verifier_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
|
||||
local verifier = {}
|
||||
math.randomseed(os.time())
|
||||
math.randomseed(vim.uv.hrtime())
|
||||
for _ = 1, 64 do
|
||||
local idx = math.random(1, #verifier_chars)
|
||||
table.insert(verifier, verifier_chars:sub(idx, idx))
|
||||
end
|
||||
local code_verifier = table.concat(verifier)
|
||||
|
||||
local sha_pipe = vim
|
||||
.system({
|
||||
'sh',
|
||||
'-c',
|
||||
'printf "%s" "'
|
||||
.. code_verifier
|
||||
.. '" | openssl dgst -sha256 -binary | openssl base64 -A | tr "+/" "-_" | tr -d "="',
|
||||
}, { text = true })
|
||||
:wait()
|
||||
local code_challenge = sha_pipe.stdout or ''
|
||||
local hex = vim.fn.sha256(code_verifier)
|
||||
local binary = hex:gsub('..', function(h)
|
||||
return string.char(tonumber(h, 16))
|
||||
end)
|
||||
local code_challenge = vim.base64.encode(binary):gsub('+', '-'):gsub('/', '_'):gsub('=', '')
|
||||
|
||||
local auth_url = AUTH_URL
|
||||
.. '?client_id='
|
||||
|
|
@ -283,6 +292,15 @@ function OAuthClient:auth()
|
|||
vim.notify('pending.nvim: Opening browser for Google authorization...')
|
||||
|
||||
local server = vim.uv.new_tcp()
|
||||
local server_closed = false
|
||||
local function close_server()
|
||||
if server_closed then
|
||||
return
|
||||
end
|
||||
server_closed = true
|
||||
server:close()
|
||||
end
|
||||
|
||||
server:bind('127.0.0.1', port)
|
||||
server:listen(1, function(err)
|
||||
if err then
|
||||
|
|
@ -292,6 +310,8 @@ function OAuthClient:auth()
|
|||
server:accept(conn)
|
||||
conn:read_start(function(read_err, data)
|
||||
if read_err or not data then
|
||||
conn:close()
|
||||
close_server()
|
||||
return
|
||||
end
|
||||
local code = data:match('[?&]code=([^&%s]+)')
|
||||
|
|
@ -305,7 +325,7 @@ function OAuthClient:auth()
|
|||
conn:close()
|
||||
end)
|
||||
end)
|
||||
server:close()
|
||||
close_server()
|
||||
if code then
|
||||
vim.schedule(function()
|
||||
self:_exchange_code(creds, code, code_verifier, port)
|
||||
|
|
@ -313,6 +333,13 @@ function OAuthClient:auth()
|
|||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
vim.defer_fn(function()
|
||||
if not server_closed then
|
||||
close_server()
|
||||
vim.notify('pending.nvim: OAuth callback timed out (120s).', vim.log.levels.WARN)
|
||||
end
|
||||
end, 120000)
|
||||
end
|
||||
|
||||
---@param creds pending.OAuthCredentials
|
||||
|
|
@ -333,19 +360,17 @@ function OAuthClient:_exchange_code(creds, code, code_verifier, port)
|
|||
.. '&redirect_uri='
|
||||
.. M.url_encode('http://127.0.0.1:' .. port)
|
||||
|
||||
local result = vim
|
||||
.system({
|
||||
'curl',
|
||||
'-s',
|
||||
'-X',
|
||||
'POST',
|
||||
'-H',
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
'-d',
|
||||
body,
|
||||
TOKEN_URL,
|
||||
}, { text = true })
|
||||
:wait()
|
||||
local result = M.system({
|
||||
'curl',
|
||||
'-s',
|
||||
'-X',
|
||||
'POST',
|
||||
'-H',
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
'-d',
|
||||
body,
|
||||
TOKEN_URL,
|
||||
}, { text = true })
|
||||
|
||||
if result.code ~= 0 then
|
||||
vim.notify('pending.nvim: Token exchange failed.', vim.log.levels.ERROR)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue