/** * Salena's Bookshelf — Cloudflare Worker API v2 * Paste this entire file into the Worker editor at: * dash.cloudflare.com → Workers & Pages → salena-bookshelf-api → Edit Code */ const CORS = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, X-App-Token', 'Content-Type': 'application/json', }; // Always send CORS headers — even on errors function json(data, status = 200) { return new Response(JSON.stringify(data), { status, headers: CORS }); } function genId() { return Date.now().toString(36) + Math.random().toString(36).slice(2); } export default { async fetch(request, env) { // ── CORS preflight — must respond before anything else ──────────── if (request.method === 'OPTIONS') { return new Response(null, { status: 204, headers: CORS }); } const url = new URL(request.url); const path = url.pathname; // ── DB health check ─────────────────────────────────────────────── if (!env.DB) { return json({ error: 'D1 database not bound. Go to Worker Settings → Bindings → Add D1 Database, variable name: DB' }, 500); } try { // ── Auth: all writes require X-App-Token header ───────────────── if (['POST','PUT','DELETE'].includes(request.method)) { const token = request.headers.get('X-App-Token') || ''; if (env.APP_TOKEN && token !== env.APP_TOKEN) { return json({ error: 'Unauthorized' }, 401); } } // ════════════════════════════════════════════════════ // BOOKS // ════════════════════════════════════════════════════ if (request.method === 'GET' && path === '/api/books') { const { results } = await env.DB.prepare( 'SELECT * FROM books ORDER BY date_added DESC' ).all(); return json(results.map(bookFromRow)); } if (request.method === 'POST' && path === '/api/books/bulk') { const { books } = await request.json(); await env.DB.prepare('DELETE FROM books').run(); if (books && books.length) { const stmt = env.DB.prepare( 'INSERT INTO books (id,title,author,genre,series,series_num,page_count,current_page,status,cover_url,isbn,notes,rating,rating_scale,is_ghost,google_id,date_added,date_started,date_finished) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)' ); await env.DB.batch(books.map(b => stmt.bind(...bookToRow(b)))); } return json({ success: true, count: books?.length || 0 }); } if (request.method === 'POST' && path === '/api/books') { const b = await request.json(); b.id = b.id || genId(); await env.DB.prepare( 'INSERT OR REPLACE INTO books (id,title,author,genre,series,series_num,page_count,current_page,status,cover_url,isbn,notes,rating,rating_scale,is_ghost,google_id,date_added,date_started,date_finished) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)' ).bind(...bookToRow(b)).run(); return json({ success: true, id: b.id }); } if (request.method === 'PUT' && path.startsWith('/api/books/')) { const id = path.split('/').pop(); const b = await request.json(); b.id = id; await env.DB.prepare( 'INSERT OR REPLACE INTO books (id,title,author,genre,series,series_num,page_count,current_page,status,cover_url,isbn,notes,rating,rating_scale,is_ghost,google_id,date_added,date_started,date_finished) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)' ).bind(...bookToRow(b)).run(); return json({ success: true }); } if (request.method === 'DELETE' && path.startsWith('/api/books/')) { const id = path.split('/').pop(); await env.DB.prepare('DELETE FROM books WHERE id=?').bind(id).run(); return json({ success: true }); } // ════════════════════════════════════════════════════ // MANGA // ════════════════════════════════════════════════════ if (request.method === 'GET' && path === '/api/manga') { const { results } = await env.DB.prepare( 'SELECT * FROM manga ORDER BY date_added DESC' ).all(); return json(results.map(mangaFromRow)); } if (request.method === 'POST' && path === '/api/manga/bulk') { const { manga } = await request.json(); await env.DB.prepare('DELETE FROM manga').run(); if (manga && manga.length) { const stmt = env.DB.prepare( 'INSERT INTO manga (id,title,title_en,author,mal_id,type,genres,volumes,chapters,current_vol,owned_vols,status,cover_url,bookend_style,notes,rating,rating_scale,date_added,date_started,date_finished) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)' ); await env.DB.batch(manga.map(m => stmt.bind(...mangaToRow(m)))); } return json({ success: true, count: manga?.length || 0 }); } if (request.method === 'POST' && path === '/api/manga') { const m = await request.json(); m.id = m.id || genId(); await env.DB.prepare( 'INSERT OR REPLACE INTO manga (id,title,title_en,author,mal_id,type,genres,volumes,chapters,current_vol,owned_vols,status,cover_url,bookend_style,notes,rating,rating_scale,date_added,date_started,date_finished) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)' ).bind(...mangaToRow(m)).run(); return json({ success: true, id: m.id }); } if (request.method === 'PUT' && path.startsWith('/api/manga/')) { const id = path.split('/').pop(); const m = await request.json(); m.id = id; await env.DB.prepare( 'INSERT OR REPLACE INTO manga (id,title,title_en,author,mal_id,type,genres,volumes,chapters,current_vol,owned_vols,status,cover_url,bookend_style,notes,rating,rating_scale,date_added,date_started,date_finished) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)' ).bind(...mangaToRow(m)).run(); return json({ success: true }); } if (request.method === 'DELETE' && path.startsWith('/api/manga/')) { const id = path.split('/').pop(); await env.DB.prepare('DELETE FROM manga WHERE id=?').bind(id).run(); return json({ success: true }); } // ════════════════════════════════════════════════════ // SETTINGS // ════════════════════════════════════════════════════ if (request.method === 'GET' && path === '/api/settings') { const { results } = await env.DB.prepare('SELECT key, value FROM settings').all(); const out = {}; results.forEach(r => { out[r.key] = r.value; }); return json(out); } if (request.method === 'PUT' && path.startsWith('/api/settings/')) { const key = decodeURIComponent(path.split('/').pop()); const { value } = await request.json(); await env.DB.prepare( 'INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?,?,?)' ).bind(key, value, new Date().toISOString()).run(); return json({ success: true }); } if (request.method === 'POST' && path === '/api/settings/bulk') { const { settings } = await request.json(); const stmt = env.DB.prepare( 'INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?,?,?)' ); const now = new Date().toISOString(); await env.DB.batch( Object.entries(settings).map(([k, v]) => stmt.bind(k, v, now)) ); return json({ success: true, count: Object.keys(settings).length }); } // ════════════════════════════════════════════════════ // HARDCOVER PROXY — bypasses browser CORS restriction // ════════════════════════════════════════════════════ if (request.method === 'POST' && path === '/api/series') { const body = await request.json(); const resp = await fetch('https://api.hardcover.app/v1/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const data = await resp.json(); return json(data, resp.status); } return json({ error: 'Not found' }, 404); } catch (err) { console.error('[Worker error]', err.message, err.stack); return json({ error: err.message }, 500); } } }; // ── Row mappers ──────────────────────────────────────────────────────── function bookToRow(b) { return [ String(b.id || genId()), String(b.title || 'Untitled'), String(b.author || ''), String(b.genre || ''), String(b.series || ''), b.seriesNum != null ? Number(b.seriesNum) : null, Number(b.pageCount || 0), Number(b.currentPage || 0), ['tbr','reading','read','dnf'].includes(b.status) ? b.status : 'tbr', String(b.coverUrl || ''), String(b.isbn || ''), String(b.notes || ''), Number(b.rating || 0), Number(b.ratingScale || 5), b.isGhost ? 1 : 0, String(b.googleId || ''), String(b.dateAdded || new Date().toISOString()), b.dateStarted || null, b.dateFinished || null, ]; } function bookFromRow(r) { return { id: r.id, title: r.title, author: r.author, genre: r.genre, series: r.series, seriesNum: r.series_num, pageCount: r.page_count, currentPage: r.current_page, status: r.status, coverUrl: r.cover_url, isbn: r.isbn, notes: r.notes, rating: r.rating, ratingScale: r.rating_scale, isGhost: r.is_ghost === 1, googleId: r.google_id, dateAdded: r.date_added, dateStarted: r.date_started, dateFinished: r.date_finished, }; } function mangaToRow(m) { return [ m.id, m.title || '', m.titleEn || '', m.author || '', m.malId || null, m.type || 'manga', m.genres || '', m.volumes || 0, m.chapters || 0, m.currentVol || 0, JSON.stringify(Array.isArray(m.ownedVols) ? m.ownedVols : []), m.status || 'ptw', m.coverUrl || '', m.bookendStyle || null, m.notes || '', m.rating || 0, m.ratingScale || 5, m.dateAdded || new Date().toISOString(), m.dateStarted || null, m.dateFinished || null, ]; } function mangaFromRow(r) { let ownedVols = []; try { ownedVols = JSON.parse(r.owned_vols || '[]'); } catch {} return { id: r.id, title: r.title, titleEn: r.title_en, author: r.author, malId: r.mal_id, type: r.type, genres: r.genres, volumes: r.volumes, chapters: r.chapters, currentVol: r.current_vol, ownedVols, status: r.status, coverUrl: r.cover_url, bookendStyle: r.bookend_style, notes: r.notes, rating: r.rating, ratingScale: r.rating_scale, dateAdded: r.date_added, dateStarted: r.date_started, dateFinished: r.date_finished, }; }