import { config } from 'dotenv'; import { Hono } from 'hono'; import { serveStatic } from 'hono/bun'; import { cors } from 'hono/cors'; import PocketBase from 'pocketbase'; import { ConfidentialClientApplication } from '@azure/msal-node'; // --------------------------------------------------------------------------- // Config // --------------------------------------------------------------------------- config({ path: '/home/admin/secrets/.env' }); // Known OneNote target // Section/page IDs must come from the OneNote Graph API (sites/{siteId}/onenote/sections), // NOT from SharePoint drive item listings (those are different GUIDs). const ONENOTE_TARGET = { siteHost: 'czflex.sharepoint.com', sitePath: '/sites/Team', // Placeholder IDs — will be verified/replaced via GET /api/onenote/site-sections sectionId: '', sectionName: 'Billing Notes', pageId: '', pageTitle: 'Billing Notes By Job', notebookName: 'Billing Notes', }; // --------------------------------------------------------------------------- // App // --------------------------------------------------------------------------- const app = new Hono(); app.use('/*', cors({ origin: '*', credentials: true })); // --------------------------------------------------------------------------- // PocketBase // --------------------------------------------------------------------------- const PB_BASE_URL = process.env.PB_URL || process.env.PB_DB || ''; const PB_AUTH_COLLECTION = process.env.PB_AUTH_COLLECTION || 'Users'; const PB_OAUTH_PROVIDER = 'microsoft'; const pb = new PocketBase(PB_BASE_URL); function setPbToken(token: string) { pb.authStore.save(token, null); } function clearPbToken() { pb.authStore.clear(); } async function validatePbToken(token: string): Promise { setPbToken(token); await pb.collection(PB_AUTH_COLLECTION).authRefresh(); } // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- function getBearerToken(header: string | undefined | null): string | null { const m = (header || '').match(/^Bearer\s+(.+)$/i); return m?.[1] || null; } function escapeHtml(s: string): string { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // --------------------------------------------------------------------------- // MSAL app-only (Graph status check) // --------------------------------------------------------------------------- const cca = new ConfidentialClientApplication({ auth: { clientId: process.env.CLIENT_ID || '', authority: `https://login.microsoftonline.com/${process.env.TENANT_ID}`, clientSecret: process.env.CLIENT_SECRET || '', }, }); let graphTokenCache: { token: string; expiresOn: number } | null = null; async function getGraphToken() { const now = Date.now(); if (graphTokenCache && graphTokenCache.expiresOn - 60_000 > now) return graphTokenCache; const result = await cca.acquireTokenByClientCredential({ scopes: ['https://graph.microsoft.com/.default'] }); if (!result?.accessToken) throw new Error('Failed to acquire Graph token'); graphTokenCache = { token: result.accessToken, expiresOn: result.expiresOn ? new Date(result.expiresOn).getTime() : now + 55 * 60_000, }; return graphTokenCache; } // Site ID cache — resolved once per server lifetime using a delegated token let cachedSiteId: string | null = null; async function resolveSiteId(delegatedToken: string): Promise { if (cachedSiteId) return cachedSiteId; const resp = await fetch( `https://graph.microsoft.com/v1.0/sites/${ONENOTE_TARGET.siteHost}:${ONENOTE_TARGET.sitePath}`, { headers: { Authorization: `Bearer ${delegatedToken}` } }, ); if (!resp.ok) { const err = await resp.text(); throw new Error(`Site ID resolution failed (${resp.status}): ${err}`); } const data = await resp.json(); if (!data?.id) throw new Error('Graph did not return a site ID'); cachedSiteId = data.id as string; console.log('site_id_resolved', { siteId: cachedSiteId }); return cachedSiteId; } // --------------------------------------------------------------------------- // Routes // --------------------------------------------------------------------------- app.get('/health', (c) => c.json({ ok: true, pbDB: PB_BASE_URL })); app.get('/api/auth/pocketbase-config', (c) => { if (!PB_BASE_URL) { return c.json({ success: false, message: 'Missing PB_URL or PB_DB' }, 500); } return c.json({ success: true, pbUrl: PB_BASE_URL, collection: PB_AUTH_COLLECTION, provider: PB_OAUTH_PROVIDER, }); }); app.get('/api/auth/msal-config', (c) => { const clientId = process.env.CLIENT_ID || ''; const tenantId = process.env.TENANT_ID || ''; const redirectUri = process.env.MSAL_REDIRECT_URI || ''; if (!clientId || !tenantId) { return c.json({ success: false, message: 'Missing CLIENT_ID or TENANT_ID' }, 500); } return c.json({ success: true, clientId, tenantId, authority: `https://login.microsoftonline.com/${tenantId}`, redirectUri, }); }); app.get('/api/graph/status', async (c) => { try { const t = await getGraphToken(); return c.json({ active: true, expiresOnISO: new Date(t.expiresOn).toISOString() }); } catch (e) { return c.json({ active: false, error: (e as Error)?.message }, 500); } }); // GET /api/onenote/site-sections // Requires: Authorization: Bearer // Resolves site ID then lists OneNote sections within that site only (no tenant-wide scan). app.get('/api/onenote/site-sections', async (c) => { try { const delegatedToken = getBearerToken(c.req.header('authorization')); if (!delegatedToken) return c.json({ success: false, message: 'Missing delegated token' }, 401); const siteId = await resolveSiteId(delegatedToken); const resp = await fetch( `https://graph.microsoft.com/v1.0/sites/${siteId}/onenote/sections?$top=100&$select=id,displayName`, { headers: { Authorization: `Bearer ${delegatedToken}` } }, ); const data = await resp.json().catch(() => ({})); if (!resp.ok) return c.json({ success: false, siteId, error: data }, resp.status as any); return c.json({ success: true, siteId, sections: data?.value || [] }); } catch (e) { return c.json({ success: false, message: (e as Error)?.message }, 500); } }); // --------------------------------------------------------------------------- // POST /api/onenote/append // Requires: x-pb-token (PocketBase session) + Authorization: Bearer // Body: { noteId, title, bodyPlain, noteType?, userEmail?, jobNumber?, sectionId? } // // Strategy: // 1. Resolve site ID via GET /sites/{host}:{path} // 2. Try PATCH sites/{siteId}/onenote/pages/{pageId}/content → append to known page // 3. If that fails, POST sites/{siteId}/onenote/sections/{sectionId}/pages → create new page // --------------------------------------------------------------------------- app.post('/api/onenote/append', async (c) => { try { /* ---- auth ---- */ const pbToken = c.req.header('x-pb-token'); if (!pbToken) return c.json({ success: false, message: 'Missing x-pb-token' }, 401); try { await validatePbToken(pbToken); } catch (e) { return c.json({ success: false, message: 'Invalid PocketBase token' }, 401); } const delegatedToken = getBearerToken(c.req.header('authorization')); if (!delegatedToken) return c.json({ success: false, message: 'Missing delegated Graph bearer token' }, 401); /* ---- payload ---- */ const body = await c.req.json(); const noteId = String(body?.noteId || '').trim(); const title = String(body?.title || 'Untitled').trim(); const bodyPlain = String(body?.bodyPlain || '').trim(); const noteType = String(body?.noteType || 'personal').trim(); const userEmail = String(body?.userEmail || 'unknown').trim(); const jobNumber = String(body?.jobNumber || '').trim(); if (!noteId) return c.json({ success: false, message: 'Missing noteId' }, 400); if (!bodyPlain) return c.json({ success: false, message: 'Missing bodyPlain' }, 400); const ts = new Date().toISOString(); const safeBody = escapeHtml(bodyPlain).replace(/\r\n|\r|\n/g, '
'); const headerLine = [ `${escapeHtml(title)}`, `type: ${escapeHtml(noteType)}`, `user: ${escapeHtml(userEmail)}`, jobNumber ? `job: ${escapeHtml(jobNumber)}` : '', `note_id: ${escapeHtml(noteId)}`, ].filter(Boolean).join(' | '); const appendHtml = `

${headerLine}

${safeBody}

---

`; const pageHtml = [ '', '', `${escapeHtml(title)}`, `${appendHtml}`, '', ].join(''); const sectionId = String(body?.sectionId || ONENOTE_TARGET.sectionId || '').trim(); const pageId = String(body?.pageId || ONENOTE_TARGET.pageId || '').trim(); if (!sectionId) return c.json({ success: false, message: 'No sectionId — run GET /api/onenote/site-sections first to retrieve valid IDs' }, 400); const authHeader = { Authorization: `Bearer ${delegatedToken}` }; /* ---- resolve site ID ---- */ const siteId = await resolveSiteId(delegatedToken); const base = `https://graph.microsoft.com/v1.0/sites/${siteId}/onenote`; /* ---- 1: try append to known page (if we have a valid pageId) ---- */ if (pageId) { const patchResp = await fetch( `${base}/pages/${pageId}/content`, { method: 'PATCH', headers: { ...authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify([{ target: 'body', action: 'append', content: appendHtml }]), }, ); if (patchResp.ok) { console.log('onenote_append_success', { mode: 'patch', noteId, siteId }); return c.json({ success: true, mode: 'appended', syncedAt: ts }); } const patchErr = await patchResp.text(); console.warn('onenote_patch_failed', { status: patchResp.status, error: patchErr }); } /* ---- 2: create new page in known section ---- */ const postResp = await fetch( `${base}/sections/${sectionId}/pages`, { method: 'POST', headers: { ...authHeader, 'Content-Type': 'text/html' }, body: pageHtml, }, ); if (postResp.ok) { const created = await postResp.json().catch(() => ({})); console.log('onenote_create_success', { mode: 'create', noteId, pageId: created?.id, siteId }); return c.json({ success: true, mode: 'created', pageId: created?.id || null, syncedAt: ts }); } const postErr = await postResp.text(); console.error('onenote_create_failed', { status: postResp.status, postErr, siteId, sectionId }); return c.json( { success: false, message: `OneNote write failed (create ${postResp.status})`, details: { create: postErr, siteId, sectionId } }, 502, ); } catch (e) { console.error('onenote_append_error', { message: (e as Error)?.message }); return c.json({ success: false, message: (e as Error)?.message || 'OneNote append failed' }, 500); } finally { clearPbToken(); } }); // --------------------------------------------------------------------------- // POST /api/submit — save a note record to PocketBase Notes collection // --------------------------------------------------------------------------- const PB_NOTES_COLLECTION = process.env.PB_NOTES_COLLECTION || 'Notes'; app.post('/api/submit', async (c) => { try { const body = await c.req.json(); if (!body?.pbToken) return c.json({ success: false, message: 'Missing pbToken' }, 401); try { await validatePbToken(body.pbToken); } catch (e) { return c.json({ success: false, message: 'Invalid token' }, 401); } const user = pb.authStore.model; const bodyPlain = String(body.bodyPlain || '').trim(); const title = String(body.title || 'Untitled').trim(); const noteType = String(body.noteType || 'personal').trim(); const jobNumber = String(body.jobNumber || '').trim(); // Convert plain text to simple HTML (matches what the main app stores) const bodyHtml = bodyPlain .split(/\r\n|\r|\n/) .map((line) => `

${line || ' '}

`) .join(''); const payload: Record = { body_plain: bodyPlain, body_html: bodyHtml, title, type: noteType, email: user?.email || body.userEmail || '', Username: user?.name || user?.username || user?.email || '', userId: user?.id || '', job_note: noteType === 'job' || !!jobNumber, shared: false, shared_with: [], }; if (jobNumber) { payload['Job_Number'] = jobNumber; } const record = await pb.collection(PB_NOTES_COLLECTION).create(payload); console.log('submission_success', { recordId: record.id }); return c.json({ success: true, recordId: record.id }); } catch (err) { console.error('submit_error', { message: (err as Error)?.message }); return c.json({ success: false, message: (err as Error)?.message || 'Internal error' }, 500); } finally { clearPbToken(); } }); // --------------------------------------------------------------------------- // Static assets // --------------------------------------------------------------------------- app.use('/images/*', serveStatic({ root: new URL('./', import.meta.url).pathname })); app.use('/tools/*', serveStatic({ root: new URL('./', import.meta.url).pathname })); app.get('/vendor/msal-browser.js', async (c) => { const file = Bun.file(new URL('./node_modules/@azure/msal-browser/lib/msal-browser.min.js', import.meta.url)); if (!(await file.exists())) return c.text('MSAL browser bundle not found', 404); c.header('Content-Type', 'application/javascript; charset=utf-8'); return c.body(await file.text()); }); app.get('/', async (c) => { const html = await Bun.file(new URL('./index.html', import.meta.url)).text(); return c.html(html); }); // --------------------------------------------------------------------------- // Start // --------------------------------------------------------------------------- const PORT = Number(process.env.PORT || 3030); Bun.serve({ port: PORT, fetch: app.fetch }); console.log(`Prism Notes running at http://localhost:${PORT}`);