Autosave: 20260405-182715

This commit is contained in:
Flatlogic Bot 2026-04-05 18:27:15 +00:00
parent d3ec77b828
commit 68e6ac5ac1
4 changed files with 2261 additions and 190 deletions

View File

@ -1,26 +1,23 @@
const express = require('express'); const express = require('express');
const config = require('../config');
const path = require('path');
const passport = require('passport'); const passport = require('passport');
const services = require('../services/file'); const services = require('../services/file');
const router = express.Router(); const router = express.Router();
router.get('/download', (req, res) => { router.get('/download', (req, res) => {
if (process.env.NODE_ENV == "production" || process.env.NEXT_PUBLIC_BACK_API) { if (process.env.NODE_ENV == 'production' || process.env.NEXT_PUBLIC_BACK_API) {
services.downloadGCloud(req, res); services.downloadGCloud(req, res);
} } else {
else {
services.downloadLocal(req, res); services.downloadLocal(req, res);
} }
}); });
router.post('/upload/:table/:field', passport.authenticate('jwt', {session: false}), (req, res) => { router.post('/upload/:table/:field', passport.authenticate('jwt', { session: false }), (req, res) => {
const fileName = `${req.params.table}/${req.params.field}`; const fileName = `${req.params.table}/${req.params.field}`;
if (process.env.NODE_ENV == "production" || process.env.NEXT_PUBLIC_BACK_API) { if (process.env.NODE_ENV == 'production' || process.env.NEXT_PUBLIC_BACK_API) {
services.uploadGCloud(fileName, req, res); services.uploadGCloud(fileName, req, res);
} } else {
else {
services.uploadLocal(fileName, { services.uploadLocal(fileName, {
entity: null, entity: null,
maxFileSize: 10 * 1024 * 1024, maxFileSize: 10 * 1024 * 1024,
@ -29,4 +26,39 @@ router.post('/upload/:table/:field', passport.authenticate('jwt', {session: fals
} }
}); });
router.post('/upload-public/:table/:field', (req, res) => {
const fileName = `${req.params.table}/${req.params.field}`;
if (process.env.NODE_ENV == 'production' || process.env.NEXT_PUBLIC_BACK_API) {
services.uploadGCloud(fileName, req, res);
} else {
services.uploadLocal(fileName, {
entity: null,
maxFileSize: 50 * 1024 * 1024,
folderIncludesAuthenticationUid: false,
allowAnonymous: true,
allowedMimePrefixes: ['audio/', 'video/'],
})(req, res);
}
});
router.post('/fetch-public-playlist', express.json({ limit: '100kb' }), async (req, res) => {
const playlistUrl = typeof req.body?.url === 'string' ? req.body.url.trim() : '';
if (!playlistUrl) {
res.status(400).send({ message: 'A playlist URL is required.' });
return;
}
try {
const result = await services.fetchPublicTextFile(playlistUrl);
res.status(200).send(result);
} catch (error) {
console.error('Failed to fetch public playlist URL:', { url: playlistUrl, error: error.message || error });
res.status(error.statusCode || 500).send({
message: error.message || 'Failed to fetch the remote playlist URL.',
});
}
});
module.exports = router; module.exports = router;

View File

@ -1,8 +1,13 @@
const axios = require('axios');
const dns = require('dns').promises;
const formidable = require('formidable'); const formidable = require('formidable');
const fs = require('fs'); const fs = require('fs');
const net = require('net');
const config = require('../config'); const config = require('../config');
const path = require('path'); const path = require('path');
const { format } = require("util"); const { format } = require('util');
const MAX_REMOTE_PLAYLIST_BYTES = 2 * 1024 * 1024;
const ensureDirectoryExistence = (filePath) => { const ensureDirectoryExistence = (filePath) => {
const dirname = path.dirname(filePath); const dirname = path.dirname(filePath);
@ -13,7 +18,39 @@ const ensureDirectoryExistence = (filePath) => {
ensureDirectoryExistence(dirname); ensureDirectoryExistence(dirname);
fs.mkdirSync(dirname); fs.mkdirSync(dirname);
} };
const getUploadedFile = (files) => {
if (!files || !files.file) {
return null;
}
return Array.isArray(files.file) ? files.file[0] : files.file;
};
const getUploadedFilePath = (file) => {
if (!file) {
return null;
}
return file.filepath || file.path || null;
};
const getUploadedFileType = (file) => {
if (!file) {
return '';
}
return file.mimetype || file.type || '';
};
const isMimeTypeAllowed = (mimeType, validations) => {
if (!validations || !validations.allowedMimePrefixes || validations.allowedMimePrefixes.length === 0) {
return true;
}
return validations.allowedMimePrefixes.some((prefix) => mimeType.startsWith(prefix));
};
const uploadLocal = ( const uploadLocal = (
folder, folder,
@ -21,30 +58,24 @@ const uploadLocal = (
entity: null, entity: null,
maxFileSize: null, maxFileSize: null,
folderIncludesAuthenticationUid: false, folderIncludesAuthenticationUid: false,
allowAnonymous: false,
allowedMimePrefixes: null,
}, },
) => { ) => {
return (req, res) => { return (req, res) => {
if (!req.currentUser) { if (!req.currentUser && !validations.allowAnonymous) {
res.sendStatus(403); res.sendStatus(403);
return; return;
} }
if ( if (validations.entity) {
validations.entity
) {
res.sendStatus(403); res.sendStatus(403);
return; return;
} }
if (validations.folderIncludesAuthenticationUid) { if (validations.folderIncludesAuthenticationUid) {
folder = folder.replace( folder = folder.replace(':userId', req.currentUser.authenticationUid);
':userId', if (!req.currentUser.authenticationUid || !folder.includes(req.currentUser.authenticationUid)) {
req.currentUser.authenticationUid,
);
if (
!req.currentUser.authenticationUid ||
!folder.includes(req.currentUser.authenticationUid)
) {
res.sendStatus(403); res.sendStatus(403);
return; return;
} }
@ -58,140 +89,311 @@ const uploadLocal = (
} }
form.parse(req, function (err, fields, files) { form.parse(req, function (err, fields, files) {
const filename = String(fields.filename); if (err) {
const fileTempUrl = files.file.path; console.error('File upload parse failed:', err);
res.status(500).send(err);
return;
}
if (!filename) { const filename = String(fields.filename);
const uploadedFile = getUploadedFile(files);
const fileTempUrl = getUploadedFilePath(uploadedFile);
const mimeType = getUploadedFileType(uploadedFile);
if (!fileTempUrl || !uploadedFile) {
res.status(400).send({ message: 'Please upload a file.' });
return;
}
if (!isMimeTypeAllowed(mimeType, validations)) {
fs.unlinkSync(fileTempUrl);
res.status(400).send({ message: 'Invalid file type.' });
return;
}
if (!filename || filename === 'undefined') {
fs.unlinkSync(fileTempUrl); fs.unlinkSync(fileTempUrl);
res.sendStatus(500); res.sendStatus(500);
return; return;
} }
const privateUrl = path.join( const privateUrl = path.join(folder, filename);
form.uploadDir, const destinationPath = path.join(form.uploadDir, privateUrl);
folder, ensureDirectoryExistence(destinationPath);
filename, fs.renameSync(fileTempUrl, destinationPath);
); res.status(200).send({ privateUrl });
ensureDirectoryExistence(privateUrl);
fs.renameSync(fileTempUrl, privateUrl);
res.sendStatus(200);
}); });
form.on('error', function (err) { form.on('error', function (err) {
console.error('File upload failed:', err);
res.status(500).send(err); res.status(500).send(err);
}); });
} };
} };
const downloadLocal = async (req, res) => { const downloadLocal = async (req, res) => {
const privateUrl = req.query.privateUrl; const privateUrl = req.query.privateUrl;
if (!privateUrl) { if (!privateUrl) {
return res.sendStatus(404); return res.sendStatus(404);
}
return res.download(path.join(config.uploadDir, privateUrl));
};
const isPrivateIpv4 = (ip) => {
const [firstOctet, secondOctet] = ip.split('.').map((segment) => Number(segment));
if (firstOctet === 10 || firstOctet === 127 || firstOctet === 0) {
return true;
}
if (firstOctet === 169 && secondOctet === 254) {
return true;
}
if (firstOctet === 172 && secondOctet >= 16 && secondOctet <= 31) {
return true;
}
if (firstOctet === 192 && secondOctet === 168) {
return true;
}
return false;
};
const isPrivateIpv6 = (ip) => {
const loweredIp = ip.toLowerCase();
return (
loweredIp === '::1' ||
loweredIp === '::' ||
loweredIp.startsWith('fc') ||
loweredIp.startsWith('fd') ||
loweredIp.startsWith('fe80')
);
};
const isPrivateIp = (ip) => {
const family = net.isIP(ip);
if (family === 4) {
return isPrivateIpv4(ip);
}
if (family === 6) {
return isPrivateIpv6(ip);
}
return true;
};
const assertRemoteUrlIsPublic = async (remoteUrl) => {
let parsedUrl;
try {
parsedUrl = new URL(remoteUrl);
} catch (error) {
const invalidUrlError = new Error('Use a valid http:// or https:// playlist URL.');
invalidUrlError.statusCode = 400;
throw invalidUrlError;
}
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
const invalidProtocolError = new Error('Only http:// and https:// playlist URLs are supported.');
invalidProtocolError.statusCode = 400;
throw invalidProtocolError;
}
const hostname = parsedUrl.hostname.toLowerCase();
if (hostname === 'localhost' || hostname.endsWith('.local')) {
const localhostError = new Error('Local network playlist URLs are not allowed.');
localhostError.statusCode = 400;
throw localhostError;
}
if (net.isIP(hostname) && isPrivateIp(hostname)) {
const privateIpError = new Error('Private network playlist URLs are not allowed.');
privateIpError.statusCode = 400;
throw privateIpError;
}
const resolvedAddresses = await dns.lookup(parsedUrl.hostname, { all: true });
if (!resolvedAddresses.length) {
const dnsError = new Error('Could not resolve the playlist host.');
dnsError.statusCode = 400;
throw dnsError;
}
if (resolvedAddresses.some((entry) => isPrivateIp(entry.address))) {
const resolvedPrivateIpError = new Error('Playlist host resolves to a private network address.');
resolvedPrivateIpError.statusCode = 400;
throw resolvedPrivateIpError;
}
return parsedUrl.toString();
};
const fetchPublicTextFile = async (remoteUrl) => {
const safeUrl = await assertRemoteUrlIsPublic(remoteUrl);
try {
const response = await axios.get(safeUrl, {
responseType: 'text',
timeout: 15000,
maxContentLength: MAX_REMOTE_PLAYLIST_BYTES,
maxBodyLength: MAX_REMOTE_PLAYLIST_BYTES,
transformResponse: [(data) => data],
headers: {
Accept: 'application/json, application/x-mpegURL, application/vnd.apple.mpegurl, audio/mpegurl, text/plain;q=0.9,*/*;q=0.5',
},
});
if (typeof response.data !== 'string') {
const responseTypeError = new Error('The remote playlist must return text-based JSON, M3U, or M3U8 content.');
responseTypeError.statusCode = 400;
throw responseTypeError;
} }
res.download(path.join(config.uploadDir, privateUrl));
} return {
url: safeUrl,
body: response.data,
contentType: response.headers['content-type'] || '',
};
} catch (error) {
if (error.response) {
console.error('Remote playlist fetch failed:', {
url: safeUrl,
status: error.response.status,
data: error.response.data,
});
const remoteResponseError = new Error(`Remote server responded with status ${error.response.status}.`);
remoteResponseError.statusCode = error.response.status;
throw remoteResponseError;
}
if (error.code === 'ERR_BAD_RESPONSE' || error.code === 'ERR_BAD_REQUEST') {
console.error('Remote playlist response handling failed:', { url: safeUrl, message: error.message });
const remoteBodyError = new Error('The remote playlist response could not be processed.');
remoteBodyError.statusCode = 400;
throw remoteBodyError;
}
if (error.code === 'ECONNABORTED') {
console.error('Remote playlist request timed out:', { url: safeUrl, message: error.message });
const timeoutError = new Error('Timed out while fetching the remote playlist.');
timeoutError.statusCode = 504;
throw timeoutError;
}
if (error.message && error.message.includes('maxContentLength')) {
console.error('Remote playlist exceeded size limit:', { url: safeUrl, message: error.message });
const sizeError = new Error('Remote playlist is larger than the 2 MB import limit.');
sizeError.statusCode = 413;
throw sizeError;
}
if (error.statusCode) {
throw error;
}
console.error('Remote playlist fetch failed:', { url: safeUrl, error });
const unknownError = new Error('Failed to fetch the remote playlist URL.');
unknownError.statusCode = 500;
throw unknownError;
}
};
const initGCloud = () => { const initGCloud = () => {
const processFile = require("../middlewares/upload"); const processFile = require('../middlewares/upload');
const { Storage } = require("@google-cloud/storage"); const { Storage } = require('@google-cloud/storage');
const crypto = require('crypto') const hash = config.gcloud.hash;
const hash = config.gcloud.hash const privateKey = process.env.GC_PRIVATE_KEY.replace(/\\\n/g, '\n');
const privateKey = process.env.GC_PRIVATE_KEY.replace(/\\\n/g, "\n");
const storage = new Storage({ const storage = new Storage({
projectId: process.env.GC_PROJECT_ID, projectId: process.env.GC_PROJECT_ID,
credentials: { credentials: {
client_email: process.env.GC_CLIENT_EMAIL, client_email: process.env.GC_CLIENT_EMAIL,
private_key: privateKey private_key: privateKey,
} },
}); });
const bucket = storage.bucket(config.gcloud.bucket); const bucket = storage.bucket(config.gcloud.bucket);
return {hash, bucket, processFile}; return { hash, bucket, processFile };
} };
const uploadGCloud = async (folder, req, res) => { const uploadGCloud = async (folder, req, res) => {
try { try {
const {hash, bucket, processFile} = initGCloud(); const { hash, bucket, processFile } = initGCloud();
await processFile(req, res); await processFile(req, res);
let buffer = await req.file.buffer; const buffer = await req.file.buffer;
let filename = await req.body.filename; const filename = await req.body.filename;
if (!req.file) { if (!req.file) {
return res.status(400).send({ message: "Please upload a file!" }); return res.status(400).send({ message: 'Please upload a file!' });
} }
let path = `${hash}/${folder}/${filename}`; const fullPath = `${hash}/${folder}/${filename}`;
let blob = bucket.file(path); const blob = bucket.file(fullPath);
console.log(path);
const blobStream = blob.createWriteStream({ const blobStream = blob.createWriteStream({
resumable: false, resumable: false,
}); });
blobStream.on("error", (err) => { blobStream.on('error', (err) => {
console.log('Upload error'); console.error('Upload error:', err.message);
console.log(err.message);
res.status(500).send({ message: err.message }); res.status(500).send({ message: err.message });
}); });
console.log(`https://storage.googleapis.com/${bucket.name}/${blob.name}`); blobStream.on('finish', async () => {
const publicUrl = format(`https://storage.googleapis.com/${bucket.name}/${blob.name}`);
blobStream.on("finish", async (data) => {
const publicUrl = format(
`https://storage.googleapis.com/${bucket.name}/${blob.name}`
);
res.status(200).send({ res.status(200).send({
message: "Uploaded the file successfully: " + path, message: `Uploaded the file successfully: ${fullPath}`,
url: publicUrl, url: publicUrl,
}); });
}); });
blobStream.end(buffer) blobStream.end(buffer);
} catch (err) { } catch (err) {
console.log(err); console.error('Could not upload the file:', err);
res.status(500).send({ res.status(500).send({
message: `Could not upload the file. ${err}` message: `Could not upload the file. ${err}`,
}); });
} }
} };
const downloadGCloud = async (req, res) => { const downloadGCloud = async (req, res) => {
try { try {
const {hash, bucket, processFile} = initGCloud(); const { hash, bucket } = initGCloud();
const privateUrl = await req.query.privateUrl; const privateUrl = await req.query.privateUrl;
const filePath = `${hash}/${privateUrl}`; const filePath = `${hash}/${privateUrl}`;
const file = bucket.file(filePath) const file = bucket.file(filePath);
const fileExists = await file.exists(); const fileExists = await file.exists();
if (fileExists[0]) { if (fileExists[0]) {
const stream = file.createReadStream(); const stream = file.createReadStream();
stream.pipe(res); stream.pipe(res);
} } else {
else {
res.status(404).send({ res.status(404).send({
message: "Could not download the file. " + err, message: 'Could not download the requested file.',
}); });
} }
} catch (err) { } catch (err) {
res.status(404).send({ res.status(404).send({
message: "Could not download the file. " + err, message: `Could not download the file. ${err}`,
}); });
} }
} };
const deleteGCloud = async (privateUrl) => { const deleteGCloud = async (privateUrl) => {
try { try {
const {hash, bucket, processFile} = initGCloud(); const { hash, bucket } = initGCloud();
const filePath = `${hash}/${privateUrl}`; const filePath = `${hash}/${privateUrl}`;
const file = bucket.file(filePath) const file = bucket.file(filePath);
const fileExists = await file.exists(); const fileExists = await file.exists();
if (fileExists[0]) { if (fileExists[0]) {
@ -200,7 +402,7 @@ const deleteGCloud = async (privateUrl) => {
} catch (err) { } catch (err) {
console.log(`Cannot find the file ${privateUrl}`); console.log(`Cannot find the file ${privateUrl}`);
} }
} };
module.exports = { module.exports = {
initGCloud, initGCloud,
@ -208,6 +410,6 @@ module.exports = {
downloadLocal, downloadLocal,
deleteGCloud, deleteGCloud,
uploadGCloud, uploadGCloud,
downloadGCloud downloadGCloud,
} fetchPublicTextFile,
};

View File

@ -12,6 +12,7 @@ export type HubLink = {
export type MediaPresetType = 'radio' | 'tv'; export type MediaPresetType = 'radio' | 'tv';
export type MediaPresetMode = 'audio' | 'video' | 'embed'; export type MediaPresetMode = 'audio' | 'video' | 'embed';
export type MediaPresetSourceKind = 'manual' | 'imported' | 'uploaded';
export type MediaPreset = { export type MediaPreset = {
id: string; id: string;
@ -21,6 +22,15 @@ export type MediaPreset = {
notes: string; notes: string;
mode: MediaPresetMode; mode: MediaPresetMode;
isSample?: boolean; isSample?: boolean;
sourceKind?: MediaPresetSourceKind;
privateUrl?: string;
originalFilename?: string;
durationSeconds?: number;
thumbnailUrl?: string;
folder?: string;
remotePlaylistUrl?: string;
remoteRefreshIntervalMinutes?: number;
lastRemoteRefreshAt?: string;
}; };
export type AdminShortcut = { export type AdminShortcut = {
@ -122,6 +132,7 @@ export const starterMediaPresets: MediaPreset[] = [
notes: 'Replace this with your live radio stream URL when you are ready.', notes: 'Replace this with your live radio stream URL when you are ready.',
mode: 'audio', mode: 'audio',
isSample: true, isSample: true,
sourceKind: 'manual',
}, },
{ {
id: 'sample-tv', id: 'sample-tv',
@ -131,6 +142,7 @@ export const starterMediaPresets: MediaPreset[] = [
notes: 'This placeholder shows the TV widget layout until you paste a real embed or video stream.', notes: 'This placeholder shows the TV widget layout until you paste a real embed or video stream.',
mode: 'video', mode: 'video',
isSample: true, isSample: true,
sourceKind: 'manual',
}, },
]; ];

File diff suppressed because it is too large Load Diff