Autosave: 20260405-182715
This commit is contained in:
parent
d3ec77b828
commit
68e6ac5ac1
@ -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;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user