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 config = require('../config');
const path = require('path');
const passport = require('passport');
const services = require('../services/file');
const router = express.Router();
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);
}
else {
} else {
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}`;
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);
}
else {
} else {
services.uploadLocal(fileName, {
entity: null,
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;

View File

@ -1,8 +1,13 @@
const axios = require('axios');
const dns = require('dns').promises;
const formidable = require('formidable');
const fs = require('fs');
const net = require('net');
const config = require('../config');
const path = require('path');
const { format } = require("util");
const { format } = require('util');
const MAX_REMOTE_PLAYLIST_BYTES = 2 * 1024 * 1024;
const ensureDirectoryExistence = (filePath) => {
const dirname = path.dirname(filePath);
@ -13,7 +18,39 @@ const ensureDirectoryExistence = (filePath) => {
ensureDirectoryExistence(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 = (
folder,
@ -21,30 +58,24 @@ const uploadLocal = (
entity: null,
maxFileSize: null,
folderIncludesAuthenticationUid: false,
allowAnonymous: false,
allowedMimePrefixes: null,
},
) => {
return (req, res) => {
if (!req.currentUser) {
if (!req.currentUser && !validations.allowAnonymous) {
res.sendStatus(403);
return;
}
if (
validations.entity
) {
if (validations.entity) {
res.sendStatus(403);
return;
}
if (validations.folderIncludesAuthenticationUid) {
folder = folder.replace(
':userId',
req.currentUser.authenticationUid,
);
if (
!req.currentUser.authenticationUid ||
!folder.includes(req.currentUser.authenticationUid)
) {
folder = folder.replace(':userId', req.currentUser.authenticationUid);
if (!req.currentUser.authenticationUid || !folder.includes(req.currentUser.authenticationUid)) {
res.sendStatus(403);
return;
}
@ -58,140 +89,311 @@ const uploadLocal = (
}
form.parse(req, function (err, fields, files) {
const filename = String(fields.filename);
const fileTempUrl = files.file.path;
if (err) {
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);
res.sendStatus(500);
return;
}
const privateUrl = path.join(
form.uploadDir,
folder,
filename,
);
ensureDirectoryExistence(privateUrl);
fs.renameSync(fileTempUrl, privateUrl);
res.sendStatus(200);
const privateUrl = path.join(folder, filename);
const destinationPath = path.join(form.uploadDir, privateUrl);
ensureDirectoryExistence(destinationPath);
fs.renameSync(fileTempUrl, destinationPath);
res.status(200).send({ privateUrl });
});
form.on('error', function (err) {
console.error('File upload failed:', err);
res.status(500).send(err);
});
}
}
};
};
const downloadLocal = async (req, res) => {
const privateUrl = req.query.privateUrl;
if (!privateUrl) {
return res.sendStatus(404);
const privateUrl = req.query.privateUrl;
if (!privateUrl) {
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 processFile = require("../middlewares/upload");
const { Storage } = require("@google-cloud/storage");
const processFile = require('../middlewares/upload');
const { Storage } = require('@google-cloud/storage');
const crypto = require('crypto')
const hash = config.gcloud.hash
const privateKey = process.env.GC_PRIVATE_KEY.replace(/\\\n/g, "\n");
const hash = config.gcloud.hash;
const privateKey = process.env.GC_PRIVATE_KEY.replace(/\\\n/g, '\n');
const storage = new Storage({
projectId: process.env.GC_PROJECT_ID,
credentials: {
client_email: process.env.GC_CLIENT_EMAIL,
private_key: privateKey
}
projectId: process.env.GC_PROJECT_ID,
credentials: {
client_email: process.env.GC_CLIENT_EMAIL,
private_key: privateKey,
},
});
const bucket = storage.bucket(config.gcloud.bucket);
return {hash, bucket, processFile};
}
return { hash, bucket, processFile };
};
const uploadGCloud = async (folder, req, res) => {
try {
const {hash, bucket, processFile} = initGCloud();
const { hash, bucket, processFile } = initGCloud();
await processFile(req, res);
let buffer = await req.file.buffer;
let filename = await req.body.filename;
const buffer = await req.file.buffer;
const filename = await req.body.filename;
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}`;
let blob = bucket.file(path);
console.log(path);
const fullPath = `${hash}/${folder}/${filename}`;
const blob = bucket.file(fullPath);
const blobStream = blob.createWriteStream({
resumable: false,
});
blobStream.on("error", (err) => {
console.log('Upload error');
console.log(err.message);
blobStream.on('error', (err) => {
console.error('Upload error:', err.message);
res.status(500).send({ message: err.message });
});
console.log(`https://storage.googleapis.com/${bucket.name}/${blob.name}`);
blobStream.on("finish", async (data) => {
const publicUrl = format(
`https://storage.googleapis.com/${bucket.name}/${blob.name}`
);
blobStream.on('finish', async () => {
const publicUrl = format(`https://storage.googleapis.com/${bucket.name}/${blob.name}`);
res.status(200).send({
message: "Uploaded the file successfully: " + path,
message: `Uploaded the file successfully: ${fullPath}`,
url: publicUrl,
});
});
blobStream.end(buffer)
blobStream.end(buffer);
} catch (err) {
console.log(err);
console.error('Could not upload the file:', err);
res.status(500).send({
message: `Could not upload the file. ${err}`
message: `Could not upload the file. ${err}`,
});
}
}
};
const downloadGCloud = async (req, res) => {
try {
const {hash, bucket, processFile} = initGCloud();
const { hash, bucket } = initGCloud();
const privateUrl = await req.query.privateUrl;
const filePath = `${hash}/${privateUrl}`;
const file = bucket.file(filePath)
const file = bucket.file(filePath);
const fileExists = await file.exists();
if (fileExists[0]) {
const stream = file.createReadStream();
stream.pipe(res);
}
else {
} else {
res.status(404).send({
message: "Could not download the file. " + err,
});
message: 'Could not download the requested file.',
});
}
} catch (err) {
res.status(404).send({
message: "Could not download the file. " + err,
message: `Could not download the file. ${err}`,
});
}
}
};
const deleteGCloud = async (privateUrl) => {
try {
const {hash, bucket, processFile} = initGCloud();
const { hash, bucket } = initGCloud();
const filePath = `${hash}/${privateUrl}`;
const file = bucket.file(filePath)
const file = bucket.file(filePath);
const fileExists = await file.exists();
if (fileExists[0]) {
@ -200,7 +402,7 @@ const deleteGCloud = async (privateUrl) => {
} catch (err) {
console.log(`Cannot find the file ${privateUrl}`);
}
}
};
module.exports = {
initGCloud,
@ -208,6 +410,6 @@ module.exports = {
downloadLocal,
deleteGCloud,
uploadGCloud,
downloadGCloud
}
downloadGCloud,
fetchPublicTextFile,
};

View File

@ -12,6 +12,7 @@ export type HubLink = {
export type MediaPresetType = 'radio' | 'tv';
export type MediaPresetMode = 'audio' | 'video' | 'embed';
export type MediaPresetSourceKind = 'manual' | 'imported' | 'uploaded';
export type MediaPreset = {
id: string;
@ -21,6 +22,15 @@ export type MediaPreset = {
notes: string;
mode: MediaPresetMode;
isSample?: boolean;
sourceKind?: MediaPresetSourceKind;
privateUrl?: string;
originalFilename?: string;
durationSeconds?: number;
thumbnailUrl?: string;
folder?: string;
remotePlaylistUrl?: string;
remoteRefreshIntervalMinutes?: number;
lastRemoteRefreshAt?: string;
};
export type AdminShortcut = {
@ -122,6 +132,7 @@ export const starterMediaPresets: MediaPreset[] = [
notes: 'Replace this with your live radio stream URL when you are ready.',
mode: 'audio',
isSample: true,
sourceKind: 'manual',
},
{
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.',
mode: 'video',
isSample: true,
sourceKind: 'manual',
},
];

File diff suppressed because it is too large Load Diff