diff --git a/server/permissions.ts b/server/permissions.ts new file mode 100644 index 0000000..42c30b3 --- /dev/null +++ b/server/permissions.ts @@ -0,0 +1,62 @@ +const UA = + 'Wikinewsie/1.0 (https://wikinewsie.toolforge.org; habs@toolforge.org)'; +const CACHE_TTL = 5 * 60 * 1000; + +const cache = new Map(); + +async function checkAutoConfirmed( + username: string, + lang: string, +): Promise { + const key = `${username}:${lang}`; + const cached = cache.get(key); + if (cached && Date.now() - cached.ts < CACHE_TTL) { + return cached.result; + } + + try { + const params = new URLSearchParams({ + action: 'query', + list: 'users', + ususers: username, + usprop: 'groups', + format: 'json', + }); + const res = await fetch( + `https://${lang}.wikipedia.org/w/api.php?${params}`, + { headers: { 'User-Agent': UA } }, + ); + if (!res.ok) return false; + const data = (await res.json()) as { + query?: { + users?: Array<{ + groups?: string[]; + missing?: boolean; + }>; + }; + }; + const user = data.query?.users?.[0]; + if (!user || user.missing) { + cache.set(key, { result: false, ts: Date.now() }); + return false; + } + const result = user.groups?.includes('autoconfirmed') ?? false; + cache.set(key, { result, ts: Date.now() }); + return result; + } catch { + return false; + } +} + +export async function isAutoConfirmedOnEnwikiOrTarget( + username: string, + lang: string, +): Promise { + const wikis = lang === 'en' ? ['en'] : [lang, 'en']; + for (const wiki of wikis) { + if (await checkAutoConfirmed(username, wiki)) { + return true; + } + } + return false; +} diff --git a/server/routes.ts b/server/routes.ts index 2677e78..fa8206f 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -1,419 +1,440 @@ import { Router } from 'express'; import { getPool, getTopEditedArticles, getRecentEdits, getQidForTitle } from './db.js'; import { enrichItems } from './enrich.js'; import { getYearlyPageSet, getCategoryForLang } from './petscan.js'; +import { isAutoConfirmedOnEnwikiOrTarget } from './permissions.js'; import { getLoginUrl, handleCallback, requireAuth, isOAuthConfigured } from './oauth.js'; import { addHeadline, editHeadline, deleteHeadline, getHeadlines, getArticleImage, getArticleImages, setArticleImage, deleteArticleImage, getArticleEvent, setArticleEvent, deleteArticleEvent, } from './toolsdb.js'; import { searchCommons, validateCommonsImage } from './commons.js'; import { searchEntities, validateEventType, } from './wikidata.js'; const router = Router(); const LANG_RE = /^[a-z]{2,10}(?:-[a-z0-9]+)*$/; // For all irregular names const LANG_DBNAME_OVERRIDES: Record = { 'be-tarask': 'be_x_old', }; function wikiFromLang(lang: string): string | null { if (!LANG_RE.test(lang)) return null; return `${LANG_DBNAME_OVERRIDES[lang] ?? lang.replace(/-/g, '_')}wiki`; } function clampInt(raw: unknown, def: number, min: number, max: number): number { const n = Number.parseInt(String(raw ?? ''), 10); if (Number.isNaN(n)) return def; return Math.min(Math.max(n, min), max); } function parseBool(raw: unknown, def: boolean): boolean { if (raw === undefined) return def; return raw === 'true' || raw === '1'; } router.get('/api/:wiki/top-edits', async (req, res) => { const lang = req.params.wiki; const wiki = wikiFromLang(lang); if (!wiki) return res.status(400).json({ error: 'Invalid wiki' }); const hours = clampInt(req.query.hours, 3, 1, 24); const namespace = clampInt(req.query.namespace, 0, 0, 5000); const limit = clampInt(req.query.limit, 20, 1, 100); const timely = parseBool(req.query.timely, true); const byEditors = parseBool(req.query.byEditors, true); const sortBy = byEditors ? 'editors' : 'edits'; if (!getPool(wiki)) { return res .status(503) .json({ error: 'Replica database not configured on this host' }); } try { const dbLimit = timely ? Math.max(limit * 5, 100) : limit; const rows = await getTopEditedArticles(wiki, hours, namespace, dbLimit, sortBy); let filtered = rows; if (timely) { const year = new Date().getUTCFullYear(); const pageSet = await getYearlyPageSet(year, lang); if (pageSet) { filtered = rows.filter((r) => pageSet.has(r.title)); } filtered = filtered.slice(0, limit); } const items = await enrichItems(filtered, `${lang}.wikipedia.org`); const qids = items.map((i) => i.qid).filter(Boolean) as string[]; if (qids.length > 0) { const manualImages = await getArticleImages(qids); for (const item of items) { if (!item.image && item.qid && manualImages.has(item.qid)) { item.image = manualImages.get(item.qid)!.imageUrl; } } } res.json({ generatedAt: new Date().toISOString(), project: `${lang}.wikipedia.org`, wiki: lang, hours, namespace, timely, byEditors, category: getCategoryForLang(lang), count: items.length, items, }); } catch (e) { console.error('top-edits error:', e); res.status(500).json({ error: 'Query failed' }); } }); router.get('/api/:wiki/recent-edits/:title', async (req, res) => { const lang = req.params.wiki; const wiki = wikiFromLang(lang); if (!wiki) return res.status(400).json({ error: 'Invalid wiki' }); const title = decodeURIComponent(req.params.title); const hours = clampInt(req.query.hours, 24, 1, 168); if (!getPool(wiki)) { return res .status(503) .json({ error: 'Replica database not configured on this host' }); } try { const edits = await getRecentEdits(wiki, title, hours); res.json({ title, hours, count: edits.length, edits, }); } catch (e) { console.error('recent-edits error:', e); res.status(500).json({ error: 'Query failed' }); } }); /* ----- Auth routes ----- */ router.get('/api/auth/login', (req, res) => { if (!isOAuthConfigured()) { return res.status(503).json({ error: 'OAuth not configured' }); } const raw = typeof req.query.returnTo === 'string' ? req.query.returnTo : '/'; req.session.returnTo = raw.startsWith('/') && !raw.startsWith('//') ? raw : '/'; res.redirect(getLoginUrl(req)); }); router.get('/callback', async (req, res) => { console.log('OAuth callback:', { state: req.query.state, sessionState: req.session.oauthState, sessionId: req.sessionID, hasCookie: !!req.headers.cookie, secure: req.secure, proto: req.headers['x-forwarded-proto'], }); try { await handleCallback(req); const returnTo = req.session.returnTo || '/'; delete req.session.returnTo; res.redirect(returnTo); } catch (e) { console.error('OAuth callback error:', e); res.status(500).send(`OAuth error: ${(e as Error).message}`); } }); -router.get('/api/auth/user', (req, res) => { - res.json({ username: req.session.username ?? null }); +router.get('/api/auth/user', async (req, res) => { + const username = req.session.username ?? null; + let canSetImage = false; + if (username) { + const lang = String(req.query.lang ?? 'en'); + canSetImage = await isAutoConfirmedOnEnwikiOrTarget(username, lang); + } + res.json({ username, canSetImage }); }); router.post('/api/auth/logout', (req, res) => { req.session.destroy(() => res.json({ ok: true })); }); /* ----- QID resolution (title → Wikidata QID) ----- */ const QID_RE = /^Q\d+$/; router.get('/api/:wiki/qid/:title', async (req, res) => { const lang = req.params.wiki; const wiki = wikiFromLang(lang); if (!wiki) return res.status(400).json({ error: 'Invalid wiki' }); const title = decodeURIComponent(req.params.title); try { const qid = await getQidForTitle(wiki, title); if (!qid) return res.status(404).json({ error: 'QID not found' }); res.json({ qid }); } catch (e) { console.error('qid resolve error:', e); res.status(500).json({ error: 'Query failed' }); } }); /* ----- Headline routes (keyed by Wikidata QID) ----- */ router.get('/api/:wiki/headlines/:qid', async (req, res) => { const lang = req.params.wiki; const qid = req.params.qid; if (!LANG_RE.test(lang)) return res.status(400).json({ error: 'Invalid wiki' }); if (!QID_RE.test(qid)) return res.status(400).json({ error: 'Invalid QID' }); try { const headlines = await getHeadlines(lang, qid); res.json({ qid, count: headlines.length, headlines }); } catch (e) { console.error('headlines error:', e); res.status(500).json({ error: 'Query failed' }); } }); router.post('/api/:wiki/headlines/:qid', requireAuth, async (req, res) => { const lang = String(req.params.wiki); const qid = String(req.params.qid); if (!LANG_RE.test(lang)) return res.status(400).json({ error: 'Invalid wiki' }); if (!QID_RE.test(qid)) return res.status(400).json({ error: 'Invalid QID' }); const headline = typeof req.body?.headline === 'string' ? req.body.headline.trim() : ''; if (!headline || headline.length > 500) { return res.status(400).json({ error: 'Headline must be 1-500 characters' }); } try { const row = await addHeadline(lang, qid, req.session.username!, headline); res.json(row); } catch (e) { console.error('add headline error:', e); res.status(500).json({ error: 'Query failed' }); } }); router.put('/api/:wiki/headlines/:qid/:id', requireAuth, async (req, res) => { const lang = String(req.params.wiki); const qid = String(req.params.qid); if (!LANG_RE.test(lang)) return res.status(400).json({ error: 'Invalid wiki' }); if (!QID_RE.test(qid)) return res.status(400).json({ error: 'Invalid QID' }); const headlineId = Number.parseInt(String(req.params.id), 10); const headline = typeof req.body?.headline === 'string' ? req.body.headline.trim() : ''; if (Number.isNaN(headlineId)) { return res.status(400).json({ error: 'Invalid headline ID' }); } if (!headline || headline.length > 500) { return res.status(400).json({ error: 'Headline must be 1-500 characters' }); } try { const row = await editHeadline(headlineId, req.session.username!, headline); if (!row) return res.status(404).json({ error: 'Headline not found' }); res.json(row); } catch (e) { console.error('edit headline error:', e); res.status(500).json({ error: 'Query failed' }); } }); router.delete('/api/:wiki/headlines/:qid/:id', requireAuth, async (req, res) => { const lang = String(req.params.wiki); if (!LANG_RE.test(lang)) return res.status(400).json({ error: 'Invalid wiki' }); const headlineId = Number.parseInt(String(req.params.id), 10); if (Number.isNaN(headlineId)) { return res.status(400).json({ error: 'Invalid headline ID' }); } try { const ok = await deleteHeadline(headlineId); if (!ok) return res.status(404).json({ error: 'Headline not found' }); res.json({ ok: true }); } catch (e) { console.error('delete headline error:', e); res.status(500).json({ error: 'Query failed' }); } }); /* ----- Commons image search ----- */ router.get('/api/commons-search', async (req, res) => { const q = String(req.query.q ?? '').trim(); if (q.length < 2) return res.json({ results: [] }); try { const results = await searchCommons(q, 10); res.json({ results }); } catch (e) { console.error('commons-search error:', e); res.status(500).json({ error: 'Search failed' }); } }); /* ----- Article images (manual Commons images keyed by QID) ----- */ router.get('/api/article-image/:qid', async (req, res) => { const qid = String(req.params.qid); if (!QID_RE.test(qid)) return res.status(400).json({ error: 'Invalid QID' }); try { const row = await getArticleImage(qid); if (!row) return res.status(404).json({ error: 'No image set' }); res.json(row); } catch (e) { console.error('article-image get error:', e); res.status(500).json({ error: 'Query failed' }); } }); router.post('/api/article-image/:qid', requireAuth, async (req, res) => { const qid = String(req.params.qid); if (!QID_RE.test(qid)) return res.status(400).json({ error: 'Invalid QID' }); + const lang = String(req.query.lang ?? 'en'); + if (!(await isAutoConfirmedOnEnwikiOrTarget(req.session.username!, lang))) { + const where = lang === 'en' ? 'English Wikipedia' : `${lang} or English Wikipedia`; + return res + .status(403) + .json({ error: `You must be auto-confirmed on ${where} to set images` }); + } const commonsTitle = typeof req.body?.commonsTitle === 'string' ? req.body.commonsTitle.trim() : ''; if (!commonsTitle) { return res.status(400).json({ error: 'Commons title required' }); } try { const validated = await validateCommonsImage(commonsTitle); if (!validated) { return res .status(400) .json({ error: 'Image not found on Commons or not a valid image' }); } const row = await setArticleImage( qid, commonsTitle, validated.imageUrl, req.session.username!, ); res.json(row); } catch (e) { console.error('article-image set error:', e); res.status(500).json({ error: 'Query failed' }); } }); router.delete('/api/article-image/:qid', requireAuth, async (req, res) => { const qid = String(req.params.qid); if (!QID_RE.test(qid)) return res.status(400).json({ error: 'Invalid QID' }); + const lang = String(req.query.lang ?? 'en'); + if (!(await isAutoConfirmedOnEnwikiOrTarget(req.session.username!, lang))) { + const where = lang === 'en' ? 'English Wikipedia' : `${lang} or English Wikipedia`; + return res + .status(403) + .json({ error: `You must be auto-confirmed on ${where} to manage images` }); + } try { const ok = await deleteArticleImage(qid); if (!ok) return res.status(404).json({ error: 'No image set' }); res.json({ ok: true }); } catch (e) { console.error('article-image delete error:', e); res.status(500).json({ error: 'Query failed' }); } }); /* ----- Event type search (Wikidata entity search) ----- */ router.get('/api/event-types', async (req, res) => { const q = String(req.query.q ?? '').trim(); if (q.length < 2) return res.json({ results: [] }); try { const results = await searchEntities(q, 15); res.json({ results }); } catch (e) { console.error('event-types error:', e); res.status(500).json({ error: 'Search failed' }); } }); /* ----- Article events (event instance of, keyed by QID) ----- */ router.get('/api/article-event/:qid', async (req, res) => { const qid = String(req.params.qid); if (!QID_RE.test(qid)) return res.status(400).json({ error: 'Invalid QID' }); try { const row = await getArticleEvent(qid); if (!row) return res.status(404).json({ error: 'No event set' }); res.json(row); } catch (e) { console.error('article-event get error:', e); res.status(500).json({ error: 'Query failed' }); } }); router.post('/api/article-event/:qid', requireAuth, async (req, res) => { const qid = String(req.params.qid); if (!QID_RE.test(qid)) return res.status(400).json({ error: 'Invalid QID' }); const eventQid = typeof req.body?.eventQid === 'string' ? req.body.eventQid.trim() : ''; if (!eventQid || !/^Q\d+$/.test(eventQid)) { return res.status(400).json({ error: 'Valid event QID required' }); } try { const validated = await validateEventType(eventQid); if (!validated) { return res.status(400).json({ error: 'This item is not a subclass of event on Wikidata. Please choose a different event type.', }); } const row = await setArticleEvent( qid, eventQid, validated.label, req.session.username!, ); res.json(row); } catch (e) { console.error('article-event set error:', e); res.status(500).json({ error: 'Query failed' }); } }); router.delete('/api/article-event/:qid', requireAuth, async (req, res) => { const qid = String(req.params.qid); if (!QID_RE.test(qid)) return res.status(400).json({ error: 'Invalid QID' }); try { const ok = await deleteArticleEvent(qid); if (!ok) return res.status(404).json({ error: 'No event set' }); res.json({ ok: true }); } catch (e) { console.error('article-event delete error:', e); res.status(500).json({ error: 'Query failed' }); } }); export default router; diff --git a/src/App.vue b/src/App.vue index 097c195..ed5c110 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,442 +1,442 @@ diff --git a/src/ArticleModal.vue b/src/ArticleModal.vue index 9708ce4..b50cc0d 100644 --- a/src/ArticleModal.vue +++ b/src/ArticleModal.vue @@ -1,1217 +1,1223 @@