-
-
Notifications
You must be signed in to change notification settings - Fork 14
Expand file tree
/
Copy pathapp.ts
More file actions
347 lines (301 loc) · 13.2 KB
/
Copy pathapp.ts
File metadata and controls
347 lines (301 loc) · 13.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
import express from 'express';
import session from 'express-session';
import path from 'path';
import { checkUser } from './middleware/authMiddleware.js';
import cookieParser from 'cookie-parser';
import mongoose from 'mongoose';
import http from 'http';
import { Server } from "socket.io";
import i18next from 'i18next';
import i18nMiddleware from 'i18next-http-middleware';
import settingsMiddleware from './middleware/settingsMiddleware.js';
import collectionMiddleware from './middleware/collectionMiddleware.js';
import themesConfig from './config/themes.js';
import { BASE_URL, SUPPORTED_LANGUAGES, DEFAULT_LANGUAGE, normalizeLanguage, dateLocaleFor } from './config/constants.js';
import { isOidcEnabled, getOidcButtonLabel, isLocalLoginDisabled } from './config/oidc.js';
import { connectDB } from './config/db.js';
import { migrateDatabase } from './utils/migrate.js';
// Models
import User from './models/User.js';
import BlockedIP from './models/blockedIP.js';
// Core & Registry
import { registry } from './core/registry.js';
import { loadPlugins } from './core/loadPlugins.js';
import { syncCustomPluginsOnBoot } from './core/customPluginSync.js';
import { mountPluginRoutes, pluginDispatcher } from './core/pluginRuntime.js';
import { applyPluginCustomization } from './core/pluginCustomization.js';
import { getCardLines, getCornerBadge, isTranslationKey, CORNER_POSITIONS, DEFAULT_CORNER_POSITION, SHARE_HIDDEN_FIELDS } from './core/cardFields.js';
import { importableFields } from './core/csvMapping.js';
import { MAX_ITEM_IMAGES, MAX_ITEM_IMAGE_BYTES } from './core/itemImages.js';
import { cleanupStaleItemImageUploads, ITEM_IMAGE_SWEEP_INTERVAL_MS, itemImageUrl } from './core/itemImageStorage.js';
// Routes imports
import setupRoutes from './routes/setupRoutes.js';
import pluginBuilderRoutes from './routes/pluginBuilderRoutes.js';
import pluginAssetRoutes from './routes/pluginAssetRoutes.js';
import authRoutes from './routes/authRoutes.js';
import shareRoutes from './routes/shareRoutes.js';
import adminRoutes from './routes/adminRoutes.js';
import settingsRoutes from './routes/settingsRoutes.js';
import backupRoutes from './routes/backupRoutes.js';
import itemImageRoutes from './routes/itemImageRoutes.js';
import oidcRoutes from './routes/oidcRoutes.js';
import dashboardRoute from './core/routes/dashboardRoute.js';
import collectionRoute from './core/routes/collectionRoute.js';
import searchRoute from './core/routes/searchRoute.js';
import manualAddRoute from './core/routes/manualAddRoute.js';
import csvImportRoute from './core/routes/csvImportRoute.js';
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
path: BASE_URL + '/socket.io',
});
i18next
.use(i18nMiddleware.LanguageDetector) // Detect language via query/cookie/header
.init({
fallbackLng: DEFAULT_LANGUAGE,
// Without these two, req.language keeps the regional tag the browser sends
// ('fr-FR'). It is stored on the user and used as a lookup key (TMDB), both of
// which only know the short codes, so the language is resolved to one of them here.
supportedLngs: [...SUPPORTED_LANGUAGES],
nonExplicitSupportedLngs: true,
preload: [...SUPPORTED_LANGUAGES],
resources: {
en: { translation: require('./locales/en.json') },
fr: { translation: require('./locales/fr.json') },
es: { translation: require('./locales/es.json') },
it: { translation: require('./locales/it.json') },
de: { translation: require('./locales/de.json') }
},
detection: {
order: ['querystring', 'cookie', 'header'], // detection order
caches: ['cookie']
}
});
// Basic configuration
app.set('view engine', 'ejs');
app.set('views', [path.join(__dirname, 'views'), path.join(__dirname, 'core/views')]);
// Card bodies are resolved from the plugin declarations, not inlined per grid
app.locals.getCardLines = getCardLines;
app.locals.getCornerBadge = getCornerBadge;
app.locals.isTranslationKey = isTranslationKey;
app.locals.CORNER_POSITIONS = CORNER_POSITIONS;
app.locals.DEFAULT_CORNER_POSITION = DEFAULT_CORNER_POSITION;
// A share visitor is shown the collection, not the home around it: the item page reads
// the same list the cards do, so a field kept from one is kept from the other
app.locals.SHARE_HIDDEN_FIELDS = SHARE_HIDDEN_FIELDS;
// The CSV mapping screen lists the destinations of every enabled module
app.locals.importableFields = importableFields;
// Shared by the image-manager partial so its client-side guard matches the save route.
app.locals.MAX_ITEM_IMAGES = MAX_ITEM_IMAGES;
app.locals.MAX_ITEM_IMAGE_BYTES = MAX_ITEM_IMAGE_BYTES;
// Stored item-upload paths stay deployment-independent; views resolve BASE_URL only
// when rendering them so edits and exports keep the portable value.
app.locals.itemImageUrl = itemImageUrl;
// Dates read the same way wherever a view prints one
app.locals.dateLocaleFor = dateLocaleFor;
app.set('io', io); // Expose io to routes
// Global middlewares
// nosniff on the whole static tree, because part of it is now supplied by users: an
// uploaded item image is only ever served as the image/jpeg its extension declares,
// never as whatever a browser might decide the bytes look like.
app.use(BASE_URL, express.static(path.join(__dirname, 'public'), {
setHeaders: (res) => res.setHeader('X-Content-Type-Options', 'nosniff')
}));
// Mounted with the static assets: no session, no settings, no collection lookup needed
app.use(BASE_URL + '/plugin-assets', pluginAssetRoutes);
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ limit: '50mb', extended: true }));
app.use(cookieParser());
app.use(i18nMiddleware.handle(i18next));
// Every value DVinyl has ever shipped as a placeholder, from .env.example and from the
// Unraid template. They are public, so an instance still running one signs its tokens with
// a secret anybody can read. Keep old entries here even after a template stops offering
// them: the installs that took them are exactly the ones that never changed them.
const INSECURE_DEFAULTS = new Set([
'SomeComplexPassword',
'AnotherComplexSecret',
'ChangeThisToAComplexPassword',
'ChangeThisToAComplexSecret'
]);
for (const name of ['PASSJWT', 'SESSION_SECRET'] as const) {
const v = process.env[name];
if (!v) {
throw new Error(`[SECURITY] ${name} is not defined. Please set it in your environment.`);
}
if (INSECURE_DEFAULTS.has(v)) {
throw new Error(
`[SECURITY] ${name} is still set to the placeholder value shipped with DVinyl ("${v}"), ` +
`which is public. Replace it with a unique secret in your .env file or container ` +
`settings, then restart (generate one with: openssl rand -hex 32).`
);
}
if (v.length < 32) {
console.warn(`[SECURITY WARNING] ${name} is shorter than 32 characters. Consider generating a longer random secret.`);
}
}
const session_secret = process.env.SESSION_SECRET!;
app.use(session({
secret: session_secret,
resave: false,
saveUninitialized: true,
cookie: { secure: process.env.PROD === 'true', httpOnly: true },
}));
if (process.env.PROD === 'true') {
app.set('trust proxy', 1); // Trust first proxy
}
const pkg = require('./package.json');
// Incext BASE_URL in each res.redirect call
app.use((req, res, next) => {
const redirect = res.redirect as any;
res.redirect = function (url: any) {
if (url.startsWith('/') && !url.startsWith(BASE_URL)) {
return redirect.call(res, `${BASE_URL}${url}`);
} else {
return redirect.call(res, url);
}
} as any;
next();
});
app.use(checkUser);
app.use(async (req: any, res, next) => {
// The preference of an authenticated user wins, otherwise the detected language.
// Detection hands back the tag the browser sent, region included ('fr-FR'), and
// changeLanguage is what rewrites req.language, so it runs on every request: the
// value travels into the User schema (enum) and into provider lookups keyed by the
// short code, neither of which knows a regional tag.
const language = normalizeLanguage(req.user?.language || req.language);
if (language !== req.language) {
await req.i18n.changeLanguage(language);
}
// Make translation helper and current language available to all EJS views
res.locals.t = req.t;
res.locals.currentLng = req.language;
res.locals.appVersion = pkg.version;
res.locals.baseUrl = BASE_URL;
res.locals.oidcEnabled = isOidcEnabled();
res.locals.oidcButtonLabel = getOidcButtonLabel();
res.locals.localLoginDisabled = isLocalLoginDisabled();
req.io = io;
next();
});
// Inject IO object into requests
app.use((req: any, res, next) => {
req.io = io;
next();
});
// Security: IP blocking middleware
app.use(async (req: any, res, next) => {
const clientIP = req.headers['x-forwarded-for']?.split(',')[0] || req.socket.remoteAddress;
try {
const blocked = await BlockedIP.findOne({ ip: clientIP });
if (blocked) return res.status(403).send(req.t('common.forbidden'));
next();
} catch (err) {
console.error('IP error:', err);
next();
}
});
// Resolve the active collection for the logged-in user. Must run BEFORE
// settingsMiddleware: settings are per-collection and need activeCollectionId.
app.use(collectionMiddleware);
// Load the active collection's settings (per-collection container)
app.use(settingsMiddleware);
// Installation gatekeeper middleware
app.use(async (req, res, next) => {
// Ignore paths that should not be redirected during setup
if (req.path.startsWith(BASE_URL + '/setup') ||
req.path.startsWith(BASE_URL + '/ressources') ||
req.path.startsWith(BASE_URL + '/styles') ||
req.path.startsWith(BASE_URL + '/login') ||
req.path.startsWith(BASE_URL + '/backup')) { // allow login and backup import while setting up
return next();
}
try {
const count = await User.countDocuments();
if (count === 0) {
return res.redirect(BASE_URL + '/setup');
}
} catch (e) {
console.error("Check setup error:", e);
}
next();
});
app.use((req, res, next) => {
res.locals.allThemes = themesConfig;
// Views read the registry through a per-request facade so the active
// collection's cosmetic overrides (settings.pluginCustomization) apply
applyPluginCustomization(res);
next();
});
// Dynamic manifest.json endpoint - injects BASE_URL
app.get(BASE_URL + '/manifest.json', (req, res) => {
res.set('Content-Type', 'application/json');
res.render(path.join(__dirname, 'public-tpl', 'manifest.json.ejs'));
});
// Dynamic service worker endpoint - injects BASE_URL
app.get(BASE_URL + '/sw.js', (req, res) => {
res.set('Content-Type', 'application/javascript');
res.set('Service-Worker-Allowed', BASE_URL || '/');
res.render(path.join(__dirname, 'public-tpl', 'sw.js.ejs'));
});
// Auto-discover and register every plugin under plugins/
loadPlugins();
// Route mounting
app.use(BASE_URL + '/setup', setupRoutes);
app.use(BASE_URL, authRoutes);
app.use(BASE_URL, shareRoutes);
app.use(BASE_URL + '/admin', adminRoutes);
app.use(BASE_URL + '/settings', settingsRoutes);
app.use(BASE_URL + '/create-plugin', pluginBuilderRoutes);
app.use(BASE_URL + '/backup', backupRoutes);
app.use(BASE_URL, itemImageRoutes);
if (isOidcEnabled()) {
app.use(BASE_URL, oidcRoutes);
}
app.use(BASE_URL, dashboardRoute);
app.use(BASE_URL, collectionRoute);
app.use(BASE_URL, searchRoute);
app.use(BASE_URL, manualAddRoute);
// Before the plugin dispatcher, which also serves /import/:id routes
app.use(BASE_URL, csvImportRoute);
// Plugin routers live behind a runtime dispatcher (not mounted directly on `app`)
// so custom plugins created via /create-plugin are reachable without a restart.
for (const plugin of registry.getAll()) {
mountPluginRoutes(plugin);
}
app.use(BASE_URL, pluginDispatcher);
app.use((req, res) => {
res.status(404).render('404');
});
// Database connection and server start
connectDB()
.then(async () => {
console.log('[BOOT] Running database migrations...');
await migrateDatabase();
// Re-materialize no-code plugins from the DB: re-grows plugins/<id>/ folders on
// a fresh/rebuilt container and backfills the DB from any pre-existing folders.
console.log('[BOOT] Syncing custom plugins...');
await syncCustomPluginsOnBoot();
const sweepAbandonedItemImages = async (label: string) => {
try {
const removed = await cleanupStaleItemImageUploads();
if (removed > 0) console.log(`[${label}] Removed ${removed} abandoned item image(s)`);
} catch (err) {
console.warn(`[${label}] Item image cleanup failed:`, err);
}
};
await sweepAbandonedItemImages('BOOT');
// An upload that never made it onto an item is only swept once it is a day old, so a
// boot-only pass leaves an instance that stays up for months collecting them. Repeat
// it on a timer, unref'd so it never holds the process open on its own.
setInterval(() => { void sweepAbandonedItemImages('CLEANUP'); }, ITEM_IMAGE_SWEEP_INTERVAL_MS).unref();
const port = process.env.VINYL_PORT || 3099;
server.listen(port, () => {
console.log(`[BOOT] Server started on port ${port} (BASE_URL="${BASE_URL || '/'}", env=${process.env.PROD === 'true' ? 'production' : 'development'})`);
});
})
.catch((err: any) => console.error('[BOOT] DB Error:', err));
// Socket event
// io.on('connection', (socket) => {
// console.log('Connected socket :', socket.id);
// });