martinlegend_neogdsync/oauth-proxy/worker.js
ml2310 b0de078e15
v1.0.9: fix data-loss and correctness bugs across the sync engine (rebased onto v1.0.8) (#5)
Correctness:
- Edits made while a sync runs are no longer dropped. Vault event handlers
  now ignore only paths the sync itself wrote (Syncer.syncWrites) instead of
  suppressing all events behind a global flag, and pending ops are cleared
  per-op right after success instead of in a bulk sweep that also wiped
  ops re-queued mid-sync.
- Renames now propagate to Drive: handlePush compares the Drive-side name
  and calls files.rename, so a local a.md -> b.md no longer leaves the old
  name on Drive (which later caused duplicate uploads after a rebuild).
- A 'modify' op with no index entry (index lost/reset) looks the file up on
  Drive by path before uploading, preventing duplicate files on Drive.
- Excluded paths queued in pendingOps are cleared instead of sticking in
  the "N pending" counter forever; exclusion logic is now shared between
  event handlers and the sync engine (src/exclude.ts), so user glob
  patterns apply consistently. computeDiff no longer reports
  newly-excluded snapshot entries as deletions.
- Files first seen via unknown Drive changes (syncedAt=0) now go through
  conflict handling instead of silently overwriting the remote copy.
- index.db is written via tmp-file + rename with recovery on load, so a
  crash mid-save can no longer silently reset the index.
- Multipart upload boundary is randomized; a file containing the fixed
  boundary string no longer corrupts the upload.

Auth / settings:
- authProxyUrl is actually used now (it was silently ignored) and is
  editable in settings; DriveApi credentials update in place so PathIndex
  never holds a stale instance; token cache is keyed by token+proxy.
- Exclude patterns are editable in settings (README advertised this but
  there was no UI).

Performance / UX:
- Force Pull and pull-new-from-Drive download with a small concurrency
  pool (settings.concurrency, previously dead config).
- Unknown-change resolution fetches each file's metadata once instead of
  twice.
- First run with a non-empty vault shows a notice explaining that an
  initial Force Push/Pull is required.

Security / hygiene:
- OAuth proxy escapes HTML in the callback pages (reflected XSS via
  ?error=...), and README now accurately describes that token refresh goes
  through the proxy and how to self-host it.
- Removed stale compiled artifacts (src/*.js) and dead code
  (listRevisions); fixed package.json metadata (MIT, author, scripts).
- Added vitest with unit tests for exclusion, snapshot diffing, and index
  persistence/recovery (17 tests).


Claude-Session: https://claude.ai/code/session_011DimwV9zDr1ViCXRCZPxC9

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-13 11:56:55 +08:00

195 lines
6.6 KiB
JavaScript

/**
* NeoGDSync OAuth Proxy — Cloudflare Worker
*
* Three endpoints:
* GET /authorize → redirect to Google OAuth consent screen
* GET /callback → exchange auth code for tokens, show refresh_token to user
* POST / → exchange refresh_token for access_token (used by plugin at sync time)
*
* Deploy:
* 1. wrangler secret put GOOGLE_CLIENT_ID
* 2. wrangler secret put GOOGLE_CLIENT_SECRET
* 3. wrangler deploy
*
* Free tier: workers.dev subdomain, HTTPS included, 100k req/day.
*/
const SCOPES = 'https://www.googleapis.com/auth/drive';
export default {
async fetch(request, env) {
const url = new URL(request.url);
// ── GET /authorize — start OAuth flow ──────────────────────
if (request.method === 'GET' && url.pathname === '/authorize') {
const params = new URLSearchParams({
client_id: env.GOOGLE_CLIENT_ID,
redirect_uri: `${url.origin}/callback`,
response_type: 'code',
scope: SCOPES,
access_type: 'offline',
prompt: 'consent', // always return refresh_token
});
return Response.redirect(
`https://accounts.google.com/o/oauth2/v2/auth?${params}`,
302,
);
}
// ── GET /callback — exchange code, show refresh_token ───────
if (request.method === 'GET' && url.pathname === '/callback') {
const error = url.searchParams.get('error');
if (error) return html(errorPage(error));
const code = url.searchParams.get('code');
if (!code) return html(errorPage('No authorization code received.'));
const upstream = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: env.GOOGLE_CLIENT_ID,
client_secret: env.GOOGLE_CLIENT_SECRET,
redirect_uri: `${url.origin}/callback`,
grant_type: 'authorization_code',
}).toString(),
});
const data = await upstream.json();
if (!upstream.ok) return html(errorPage(data.error ?? 'Token exchange failed.'));
return html(successPage(data.refresh_token));
}
// ── POST / — refresh access_token (plugin sync path) ────────
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders() });
}
if (request.method !== 'POST') {
return json({ error: 'method_not_allowed' }, 405);
}
let body;
try {
body = await request.json();
} catch {
return json({ error: 'invalid_json' }, 400);
}
const { refresh_token } = body;
if (!refresh_token || typeof refresh_token !== 'string') {
return json({ error: 'missing_refresh_token' }, 400);
}
const upstream = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: env.GOOGLE_CLIENT_ID,
client_secret: env.GOOGLE_CLIENT_SECRET,
refresh_token,
grant_type: 'refresh_token',
}).toString(),
});
const data = await upstream.json();
if (!upstream.ok) {
return json(
{ error: data.error ?? 'upstream_error', error_description: data.error_description },
upstream.status,
);
}
return json({ access_token: data.access_token, expires_in: data.expires_in });
},
};
// ── HTML helpers ───────────────────────────────────────────────
// Never interpolate request-derived strings into HTML unescaped — /callback?error=…
// is attacker-controlled and was a reflected XSS.
function escapeHtml(s) {
return String(s)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function successPage(refreshToken) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NeoGDSync — Connected</title>
<style>
body { font-family: -apple-system, sans-serif; max-width: 520px; margin: 60px auto; padding: 0 20px; color: #1a1a1a; }
h1 { color: #1a7f37; }
.token-box { background: #f6f8fa; border: 1px solid #d0d7de; border-radius: 6px; padding: 12px 16px; font-family: monospace; font-size: 13px; word-break: break-all; margin: 16px 0; }
.copy-btn { background: #1a7f37; color: #fff; border: none; border-radius: 6px; padding: 10px 20px; font-size: 15px; cursor: pointer; }
.copy-btn:active { background: #116329; }
p { line-height: 1.6; color: #555; }
.step { font-weight: 600; color: #1a1a1a; }
</style>
</head>
<body>
<h1>✅ Google Drive connected</h1>
<p>Copy your refresh token below, then paste it into NeoGDSync settings.</p>
<div class="token-box" id="token">${escapeHtml(refreshToken)}</div>
<button class="copy-btn" onclick="copyToken()">Copy token</button>
<p style="margin-top:24px">
<span class="step">Back in Obsidian → NeoGDSync settings:</span><br>
Paste the token into the <strong>Refresh token</strong> field (or tap <em>Paste token</em> if the button appears).
</p>
<script>
function copyToken() {
navigator.clipboard.writeText(document.getElementById('token').innerText)
.then(() => { document.querySelector('.copy-btn').textContent = 'Copied!'; })
.catch(() => { document.querySelector('.copy-btn').textContent = 'Copy failed — select manually'; });
}
</script>
</body>
</html>`;
}
function errorPage(error) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>NeoGDSync — Error</title>
<style>
body { font-family: -apple-system, sans-serif; max-width: 520px; margin: 60px auto; padding: 0 20px; }
h1 { color: #cf222e; }
</style>
</head>
<body>
<h1>❌ Authorization failed</h1>
<p>${escapeHtml(error)}</p>
<p><a href="/authorize">Try again</a></p>
</body>
</html>`;
}
function html(body, status = 200) {
return new Response(body, { status, headers: { 'Content-Type': 'text/html;charset=UTF-8' } });
}
function json(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json', ...corsHeaders() },
});
}
function corsHeaders() {
return {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
}