Named layouts lived in localStorage: invisible to anyone else, gone on the next machine, and nothing to publish from. The one server-side layout was pf.source.default_layout -- a single anonymous blob any account could overwrite for every account, which is a published layout with no owner. pf.layout replaces both. A layout is named, owned, and either private or published; published ones are listed by everyone on the forecast and writable only by their owner or an admin, the same rule pf.log already uses for its entries. Scope is the version, since that is the entry point, with version_id NULL for the source-wide default a new version inherits. Applying is never restricted -- Save is withheld on a layout that is not yours, Save as forks it -- because the guarantee wanted is that a published layout cannot be changed out from under people, not that it cannot be adapted. The toolbar's flat chip row becomes one Layout menu: Published and Mine, rename/publish/default/delete shown only where they would be allowed, and a dirty dot computed by comparing the live config against the one the pivot was applied from, since restore() fires the change event itself. Existing localStorage lists are lifted into pf.layout on first load. PUT /sources/:id/default-layout is removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
88 lines
3.1 KiB
JavaScript
88 lines
3.1 KiB
JavaScript
require('dotenv').config();
|
|
const express = require('express');
|
|
const cors = require('cors');
|
|
const session = require('express-session');
|
|
const PgSession = require('connect-pg-simple')(session);
|
|
const { Pool, types } = require('pg');
|
|
const { requireAuth } = require('./lib/auth');
|
|
|
|
// Return bigint (oid 20) and numeric (oid 1700) as JS numbers instead of strings,
|
|
// so apache-arrow's tableFromJSON infers Int/Float64 rather than Dictionary<Utf8>.
|
|
types.setTypeParser(20, v => v === null ? null : Number(v));
|
|
types.setTypeParser(1700, v => v === null ? null : Number(v));
|
|
|
|
const app = express();
|
|
|
|
// Sessions ride on a cookie, so a wildcard CORS origin would let any site make
|
|
// credentialed calls on behalf of a logged-in user. The UI is served from this
|
|
// same origin and needs no CORS at all; set CORS_ORIGIN only for a separate
|
|
// front-end host, and it is then allowed by name, never by wildcard.
|
|
if (process.env.CORS_ORIGIN) {
|
|
app.use(cors({ origin: process.env.CORS_ORIGIN.split(',').map(o => o.trim()), credentials: true }));
|
|
}
|
|
|
|
app.use(express.json());
|
|
app.use(express.static('public/app'));
|
|
|
|
const pool = new Pool({
|
|
host: process.env.DB_HOST,
|
|
port: parseInt(process.env.DB_PORT) || 5432,
|
|
database: process.env.DB_NAME,
|
|
user: process.env.DB_USER,
|
|
password: process.env.DB_PASSWORD,
|
|
ssl: false
|
|
});
|
|
|
|
pool.on('error', (err) => {
|
|
console.error('pg pool error', err);
|
|
});
|
|
|
|
// ── Authentication ────────────────────────────────────────────
|
|
// Refuse to boot without a secret rather than fall back to a default one:
|
|
// a predictable secret means forgeable session cookies.
|
|
const sessionSecret = process.env.SESSION_SECRET;
|
|
if (!sessionSecret) {
|
|
console.error('SESSION_SECRET is not set. Run: ./pf.sh config');
|
|
process.exit(1);
|
|
}
|
|
|
|
// TLS terminates at the reverse proxy, so express has to trust its headers for
|
|
// req.ip (the login throttle) and for secure-cookie detection to be right.
|
|
app.set('trust proxy', process.env.TRUST_PROXY || 1);
|
|
|
|
const cookieSecure = process.env.COOKIE_SECURE !== 'false';
|
|
if (!cookieSecure) {
|
|
console.warn('COOKIE_SECURE=false — session cookie will be sent over plain HTTP.');
|
|
}
|
|
|
|
app.use(session({
|
|
name: 'pf.sid',
|
|
store: new PgSession({ pool, schemaName: 'pf', tableName: 'session', createTableIfMissing: false }),
|
|
secret: sessionSecret,
|
|
resave: false,
|
|
saveUninitialized: false,
|
|
rolling: true,
|
|
cookie: {
|
|
httpOnly: true,
|
|
sameSite: 'lax',
|
|
secure: cookieSecure,
|
|
maxAge: 1000 * 60 * 60 * 12,
|
|
},
|
|
}));
|
|
|
|
app.use('/api', require('./routes/auth')(pool));
|
|
|
|
// Everything below this line requires a session.
|
|
app.use('/api', requireAuth);
|
|
|
|
app.use('/api', require('./routes/tables')(pool));
|
|
app.use('/api', require('./routes/sources')(pool));
|
|
app.use('/api', require('./routes/versions')(pool));
|
|
app.use('/api', require('./routes/operations')(pool));
|
|
app.use('/api', require('./routes/log')(pool));
|
|
app.use('/api', require('./routes/layouts')(pool));
|
|
|
|
|
|
const port = process.env.PORT || 3010;
|
|
app.listen(port, '0.0.0.0', () => console.log(`pf_app started on port ${port}`));
|