Adds an /admin route, visible only to users with role=admin, that lets an operator manage the user base from the UI: - list / search users (active + inactive) - create new accounts (with role and optional display name) - reset a user's password - promote/demote between admin and member - deactivate / reactivate (soft-delete via is_active flag) Backend changes: - New adminOrApiKeyMiddleware accepts EITHER a Bearer JWT belonging to a role=admin user (UI path) OR the existing X-API-Key (bot/server-to-server). - Existing /api/admin/* routes switched to the hybrid middleware, so the same endpoints serve both the dashboard and any external scripts. - Added PUT /api/admin/users/:id/role and PUT /api/admin/users/:id/reactivate. - Self-deactivation and self-demotion are explicitly blocked so an admin can't lock themselves out. Frontend changes: - New Admin.tsx page (table view, modals for create + reset, toast feedback). - Admin button in CollectionList header, only rendered for admin role. - Wired into App.tsx routing. Also: friendly error when poppler-utils is missing on the host (PDF uploads return 501 POPPLER_MISSING with a one-line install hint instead of crashing the request); README clarifies poppler is required for the manual install.
169 lines
4.5 KiB
JavaScript
169 lines
4.5 KiB
JavaScript
/**
|
|
* pdf-utils.js — Thin wrappers around poppler-utils CLI tools.
|
|
*
|
|
* Uses pdfinfo + pdftoppm (from poppler-utils) for PDF metadata extraction
|
|
* and page rendering. No external Node dependencies required.
|
|
*/
|
|
|
|
const { execFile } = require('child_process');
|
|
const { promisify } = require('util');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
const TIMEOUT_MS = 60_000;
|
|
|
|
class PopplerMissingError extends Error {
|
|
constructor(binary) {
|
|
super(
|
|
`RefBoard couldn't run "${binary}". PDF support requires poppler-utils to be installed on the host. ` +
|
|
`On macOS: brew install poppler. On Debian/Ubuntu: apt install poppler-utils. ` +
|
|
`The provided Dockerfile already installs it — this only matters for manual installs.`
|
|
);
|
|
this.code = 'POPPLER_MISSING';
|
|
this.binary = binary;
|
|
this.statusCode = 501;
|
|
}
|
|
}
|
|
|
|
function wrapEnoent(binary, fn) {
|
|
return async (...args) => {
|
|
try {
|
|
return await fn(...args);
|
|
} catch (err) {
|
|
if (err && err.code === 'ENOENT' && (err.path === binary || err.syscall === `spawn ${binary}`)) {
|
|
throw new PopplerMissingError(binary);
|
|
}
|
|
throw err;
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Write a buffer to a temporary file. Returns { tmpPath, cleanup }.
|
|
* Caller MUST call cleanup() when done.
|
|
*/
|
|
function bufferToTempFile(buffer, ext = '.bin') {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'refboard-pdf-'));
|
|
const tmpPath = path.join(tmpDir, `file${ext}`);
|
|
fs.writeFileSync(tmpPath, buffer);
|
|
return {
|
|
tmpPath,
|
|
cleanup() {
|
|
try { fs.unlinkSync(tmpPath); } catch {}
|
|
try { fs.rmdirSync(tmpDir); } catch {}
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extract PDF metadata via pdfinfo.
|
|
* Returns { pageCount, dimensions: [{ w, h }, ...] }
|
|
*/
|
|
async function pdfInfo(filePath) {
|
|
// Get basic info (page count)
|
|
const { stdout: basicOut } = await execFileAsync('pdfinfo', [filePath], { timeout: TIMEOUT_MS });
|
|
|
|
let pageCount = 0;
|
|
for (const line of basicOut.split('\n')) {
|
|
const match = line.match(/^Pages:\s+(\d+)/);
|
|
if (match) {
|
|
pageCount = parseInt(match[1], 10);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (pageCount === 0) {
|
|
throw new Error('Could not determine PDF page count');
|
|
}
|
|
|
|
// Get per-page dimensions
|
|
const dimensions = [];
|
|
const { stdout: dimOut } = await execFileAsync(
|
|
'pdfinfo',
|
|
['-f', '1', '-l', String(pageCount), filePath],
|
|
{ timeout: TIMEOUT_MS }
|
|
);
|
|
|
|
// Parse "Page N size: W x H pts" lines
|
|
for (const line of dimOut.split('\n')) {
|
|
const match = line.match(/^Page\s+\d+\s+size:\s+([\d.]+)\s+x\s+([\d.]+)/);
|
|
if (match) {
|
|
dimensions.push({
|
|
w: Math.round(parseFloat(match[1])),
|
|
h: Math.round(parseFloat(match[2])),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Fallback: if no per-page sizes found, try global "Page size:"
|
|
if (dimensions.length === 0) {
|
|
for (const line of basicOut.split('\n')) {
|
|
const match = line.match(/^Page size:\s+([\d.]+)\s+x\s+([\d.]+)/);
|
|
if (match) {
|
|
const dim = {
|
|
w: Math.round(parseFloat(match[1])),
|
|
h: Math.round(parseFloat(match[2])),
|
|
};
|
|
for (let i = 0; i < pageCount; i++) {
|
|
dimensions.push(dim);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return { pageCount, dimensions };
|
|
}
|
|
|
|
/**
|
|
* Render a single PDF page to PNG at the given DPI.
|
|
* Returns a PNG Buffer.
|
|
*/
|
|
async function pdfRenderPage(filePath, pageNum, dpi = 150) {
|
|
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'refboard-render-'));
|
|
|
|
try {
|
|
const outPrefix = path.join(outDir, 'page');
|
|
|
|
await execFileAsync('pdftoppm', [
|
|
'-png',
|
|
'-r', String(dpi),
|
|
'-f', String(pageNum),
|
|
'-l', String(pageNum),
|
|
filePath,
|
|
outPrefix,
|
|
], { timeout: TIMEOUT_MS });
|
|
|
|
// pdftoppm names output like page-01.png, page-1.png, etc. — discover it
|
|
const files = fs.readdirSync(outDir).filter(f => f.endsWith('.png'));
|
|
if (files.length === 0) {
|
|
throw new Error(`pdftoppm produced no output for page ${pageNum}`);
|
|
}
|
|
|
|
const pngPath = path.join(outDir, files[0]);
|
|
const pngBuffer = fs.readFileSync(pngPath);
|
|
return pngBuffer;
|
|
} finally {
|
|
// Clean up temp dir
|
|
try {
|
|
for (const f of fs.readdirSync(outDir)) {
|
|
fs.unlinkSync(path.join(outDir, f));
|
|
}
|
|
fs.rmdirSync(outDir);
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
const safePdfInfo = wrapEnoent('pdfinfo', pdfInfo);
|
|
const safePdfRenderPage = wrapEnoent('pdftoppm', pdfRenderPage);
|
|
|
|
module.exports = {
|
|
pdfInfo: safePdfInfo,
|
|
pdfRenderPage: safePdfRenderPage,
|
|
bufferToTempFile,
|
|
PopplerMissingError,
|
|
};
|