Merge branch 'main' into feat/mfa
This commit is contained in:
126
server/package-lock.json
generated
126
server/package-lock.json
generated
@@ -16,7 +16,7 @@
|
||||
"express": "^4.18.3",
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"multer": "^2.1.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"otplib": "^12.0.1",
|
||||
@@ -1270,50 +1270,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
|
||||
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 0.8"
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^2.2.2",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
@@ -2457,18 +2427,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
@@ -2482,22 +2440,22 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "1.4.5-lts.2",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
|
||||
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
|
||||
"deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
|
||||
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.0.0",
|
||||
"concat-stream": "^1.5.2",
|
||||
"mkdirp": "^0.5.4",
|
||||
"object-assign": "^4.1.1",
|
||||
"type-is": "^1.6.4",
|
||||
"xtend": "^4.0.0"
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"type-is": "^1.6.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
"node": ">= 10.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
@@ -3603,56 +3561,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/zip-stream": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-5.0.2.tgz",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"express": "^4.18.3",
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"multer": "^2.1.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
|
||||
@@ -283,6 +283,15 @@ function createTables(db: Database.Database): void {
|
||||
UNIQUE(plan_id, date)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vacay_holiday_calendars (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
|
||||
region TEXT NOT NULL,
|
||||
label TEXT,
|
||||
color TEXT NOT NULL DEFAULT '#fecaca',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS day_accommodations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -44,6 +44,8 @@ if (allowedOrigins) {
|
||||
corsOrigin = true;
|
||||
}
|
||||
|
||||
const shouldForceHttps = process.env.FORCE_HTTPS === 'true';
|
||||
|
||||
app.use(cors({
|
||||
origin: corsOrigin,
|
||||
credentials: true
|
||||
@@ -60,13 +62,15 @@ app.use(helmet({
|
||||
objectSrc: ["'self'"],
|
||||
frameSrc: ["'self'"],
|
||||
frameAncestors: ["'self'"],
|
||||
upgradeInsecureRequests: shouldForceHttps ? [] : null
|
||||
}
|
||||
},
|
||||
crossOriginEmbedderPolicy: false,
|
||||
hsts: process.env.FORCE_HTTPS === 'true' ? { maxAge: 31536000, includeSubDomains: false } : false,
|
||||
hsts: shouldForceHttps ? { maxAge: 31536000, includeSubDomains: false } : false,
|
||||
}));
|
||||
|
||||
// Redirect HTTP to HTTPS (opt-in via FORCE_HTTPS=true)
|
||||
if (process.env.FORCE_HTTPS === 'true') {
|
||||
if (shouldForceHttps) {
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.secure || req.headers['x-forwarded-proto'] === 'https') return next();
|
||||
res.redirect(301, 'https://' + req.headers.host + req.url);
|
||||
|
||||
@@ -43,9 +43,59 @@ interface Holiday {
|
||||
counties?: string[] | null;
|
||||
}
|
||||
|
||||
interface VacayHolidayCalendar {
|
||||
id: number;
|
||||
plan_id: number;
|
||||
region: string;
|
||||
label: string | null;
|
||||
color: string;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
const holidayCache = new Map<string, { data: unknown; time: number }>();
|
||||
const CACHE_TTL = 24 * 60 * 60 * 1000;
|
||||
|
||||
async function applyHolidayCalendars(planId: number): Promise<void> {
|
||||
const plan = db.prepare('SELECT holidays_enabled FROM vacay_plans WHERE id = ?').get(planId) as { holidays_enabled: number } | undefined;
|
||||
if (!plan?.holidays_enabled) return;
|
||||
const calendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(planId) as VacayHolidayCalendar[];
|
||||
if (calendars.length === 0) return;
|
||||
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(planId) as { year: number }[];
|
||||
for (const cal of calendars) {
|
||||
const country = cal.region.split('-')[0];
|
||||
const region = cal.region.includes('-') ? cal.region : null;
|
||||
for (const { year } of years) {
|
||||
try {
|
||||
const cacheKey = `${year}-${country}`;
|
||||
let holidays = holidayCache.get(cacheKey)?.data as Holiday[] | undefined;
|
||||
if (!holidays) {
|
||||
const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${country}`);
|
||||
holidays = await resp.json() as Holiday[];
|
||||
holidayCache.set(cacheKey, { data: holidays, time: Date.now() });
|
||||
}
|
||||
const hasRegions = holidays.some((h: Holiday) => h.counties && h.counties.length > 0);
|
||||
if (hasRegions && !region) continue;
|
||||
for (const h of holidays) {
|
||||
if (h.global || !h.counties || (region && h.counties.includes(region))) {
|
||||
db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, h.date);
|
||||
db.prepare('DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').run(planId, h.date);
|
||||
}
|
||||
}
|
||||
} catch { /* API error, skip */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateHolidayCalendars(planId: number, plan: VacayPlan): Promise<void> {
|
||||
const existing = db.prepare('SELECT id FROM vacay_holiday_calendars WHERE plan_id = ?').get(planId);
|
||||
if (existing) return;
|
||||
if (plan.holidays_enabled && plan.holidays_region) {
|
||||
db.prepare(
|
||||
'INSERT INTO vacay_holiday_calendars (plan_id, region, label, color, sort_order) VALUES (?, ?, NULL, ?, 0)'
|
||||
).run(planId, plan.holidays_region, '#fecaca');
|
||||
}
|
||||
}
|
||||
|
||||
const router = express.Router();
|
||||
router.use(authenticate);
|
||||
|
||||
@@ -124,6 +174,8 @@ router.get('/plan', (req: Request, res: Response) => {
|
||||
WHERE m.user_id = ? AND m.status = 'pending'
|
||||
`).all(authReq.user.id);
|
||||
|
||||
const holidayCalendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(activePlanId) as VacayHolidayCalendar[];
|
||||
|
||||
res.json({
|
||||
plan: {
|
||||
...plan,
|
||||
@@ -131,6 +183,7 @@ router.get('/plan', (req: Request, res: Response) => {
|
||||
holidays_enabled: !!plan.holidays_enabled,
|
||||
company_holidays_enabled: !!plan.company_holidays_enabled,
|
||||
carry_over_enabled: !!plan.carry_over_enabled,
|
||||
holiday_calendars: holidayCalendars,
|
||||
},
|
||||
users,
|
||||
pendingInvites,
|
||||
@@ -166,30 +219,8 @@ router.put('/plan', async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
const updatedPlan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan;
|
||||
if (updatedPlan.holidays_enabled && updatedPlan.holidays_region) {
|
||||
const country = updatedPlan.holidays_region.split('-')[0];
|
||||
const region = updatedPlan.holidays_region.includes('-') ? updatedPlan.holidays_region : null;
|
||||
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(planId) as { year: number }[];
|
||||
for (const { year } of years) {
|
||||
try {
|
||||
const cacheKey = `${year}-${country}`;
|
||||
let holidays = holidayCache.get(cacheKey)?.data as Holiday[] | undefined;
|
||||
if (!holidays) {
|
||||
const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${country}`);
|
||||
holidays = await resp.json() as Holiday[];
|
||||
holidayCache.set(cacheKey, { data: holidays, time: Date.now() });
|
||||
}
|
||||
const hasRegions = (holidays as Holiday[]).some((h: Holiday) => h.counties && h.counties.length > 0);
|
||||
if (hasRegions && !region) continue;
|
||||
for (const h of holidays) {
|
||||
if (h.global || !h.counties || (region && h.counties.includes(region))) {
|
||||
db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, h.date);
|
||||
db.prepare('DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').run(planId, h.date);
|
||||
}
|
||||
}
|
||||
} catch { /* API error, skip */ }
|
||||
}
|
||||
}
|
||||
await migrateHolidayCalendars(planId, updatedPlan);
|
||||
await applyHolidayCalendars(planId);
|
||||
|
||||
if (carry_over_enabled === false) {
|
||||
db.prepare('UPDATE vacay_user_years SET carried_over = 0 WHERE plan_id = ?').run(planId);
|
||||
@@ -217,11 +248,58 @@ router.put('/plan', async (req: Request, res: Response) => {
|
||||
notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings');
|
||||
|
||||
const updated = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan;
|
||||
const updatedCalendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(planId) as VacayHolidayCalendar[];
|
||||
res.json({
|
||||
plan: { ...updated, block_weekends: !!updated.block_weekends, holidays_enabled: !!updated.holidays_enabled, company_holidays_enabled: !!updated.company_holidays_enabled, carry_over_enabled: !!updated.carry_over_enabled }
|
||||
plan: { ...updated, block_weekends: !!updated.block_weekends, holidays_enabled: !!updated.holidays_enabled, company_holidays_enabled: !!updated.company_holidays_enabled, carry_over_enabled: !!updated.carry_over_enabled, holiday_calendars: updatedCalendars }
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/plan/holiday-calendars', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { region, label, color, sort_order } = req.body;
|
||||
if (!region) return res.status(400).json({ error: 'region required' });
|
||||
const planId = getActivePlanId(authReq.user.id);
|
||||
const result = db.prepare(
|
||||
'INSERT INTO vacay_holiday_calendars (plan_id, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(planId, region, label || null, color || '#fecaca', sort_order ?? 0);
|
||||
const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ?').get(result.lastInsertRowid) as VacayHolidayCalendar;
|
||||
notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings');
|
||||
res.json({ calendar: cal });
|
||||
});
|
||||
|
||||
router.put('/plan/holiday-calendars/:id', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const id = parseInt(req.params.id);
|
||||
const planId = getActivePlanId(authReq.user.id);
|
||||
const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?').get(id, planId) as VacayHolidayCalendar | undefined;
|
||||
if (!cal) return res.status(404).json({ error: 'Calendar not found' });
|
||||
const { region, label, color, sort_order } = req.body;
|
||||
const updates: string[] = [];
|
||||
const params: (string | number | null)[] = [];
|
||||
if (region !== undefined) { updates.push('region = ?'); params.push(region); }
|
||||
if (label !== undefined) { updates.push('label = ?'); params.push(label); }
|
||||
if (color !== undefined) { updates.push('color = ?'); params.push(color); }
|
||||
if (sort_order !== undefined) { updates.push('sort_order = ?'); params.push(sort_order); }
|
||||
if (updates.length > 0) {
|
||||
params.push(id);
|
||||
db.prepare(`UPDATE vacay_holiday_calendars SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
||||
}
|
||||
const updated = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ?').get(id) as VacayHolidayCalendar;
|
||||
notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings');
|
||||
res.json({ calendar: updated });
|
||||
});
|
||||
|
||||
router.delete('/plan/holiday-calendars/:id', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const id = parseInt(req.params.id);
|
||||
const planId = getActivePlanId(authReq.user.id);
|
||||
const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?').get(id, planId);
|
||||
if (!cal) return res.status(404).json({ error: 'Calendar not found' });
|
||||
db.prepare('DELETE FROM vacay_holiday_calendars WHERE id = ?').run(id);
|
||||
notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings');
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.put('/color', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { color, target_user_id } = req.body;
|
||||
|
||||
Reference in New Issue
Block a user