1
Fork 0
mirror of https://github.com/diced/zipline.git synced 2025-04-11 23:31:17 -05:00
* fix: password check before any checking if previewable

* chore: add debug logs for raw route and use nodejs' built in pipeline instead of pump

* chore: hook request instead of reply for debug request logger

* fix: the meta tags return to their natural order, and add a fallback text if the browser can't play the video

* chore: narrower typing

* feat: gif thumbnails, discord doesn't play it sadly :(

* fix: turn gif thumbnails into an opt in feature. might be taxing

* fix: nevermind on that narrower typing

* fix: prettier :(
This commit is contained in:
Jay 2025-02-17 12:43:50 -08:00 committed by GitHub
parent df84edd310
commit 478baeca83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 198 additions and 106 deletions

View file

@ -125,6 +125,17 @@ export default function Type({ file, popup = false, disableMediaPreview, ...prop
);
};
if (file.password) {
return (
<Placeholder
Icon={IconFileAlert}
text={`This file is password protected. Click to view file (${file.name})`}
onClick={() => window.open(file.url)}
{...props}
/>
);
}
if ((shouldRenderMarkdown || shouldRenderTex || shouldRenderCode) && !props.overrideRender && popup)
return (
<>
@ -143,17 +154,6 @@ export default function Type({ file, popup = false, disableMediaPreview, ...prop
return <Placeholder Icon={IconFile} text={`Click to view file (${file.name})`} {...props} />;
}
if (file.password) {
return (
<Placeholder
Icon={IconFileAlert}
text={`This file is password protected. Click to view file (${file.name})`}
onClick={() => window.open(file.url)}
{...props}
/>
);
}
return popup ? (
media ? (
{

View file

@ -119,6 +119,7 @@ export interface ConfigFeatures {
robots_txt: string;
thumbnails: boolean;
gif_thumbnails: boolean;
}
export interface ConfigOAuth {

View file

@ -164,6 +164,7 @@ export default function readConfig() {
map('FEATURES_ROBOTS_TXT', 'boolean', 'features.robots_txt'),
map('FEATURES_THUMBNAILS', 'boolean', 'features.thumbnails'),
map('FEATURES_GIF_THUMBNAILS', 'boolean', 'features.gif_thumbnails'),
map('CHUNKS_MAX_SIZE', 'human-to-byte', 'chunks.max_size'),
map('CHUNKS_CHUNKS_SIZE', 'human-to-byte', 'chunks.chunks_size'),

View file

@ -191,6 +191,7 @@ const validator = s.object({
default_avatar: s.string.nullable.default(null),
robots_txt: s.boolean.default(false),
thumbnails: s.boolean.default(false),
gif_thumbnails: s.boolean.default(false),
})
.default({
invites: false,
@ -202,6 +203,7 @@ const validator = s.object({
default_avatar: null,
robots_txt: false,
thumbnails: false,
gif_thumbnails: false,
}),
chunks: s
.object({

View file

@ -1,11 +1,12 @@
import type { File, User, Url } from '@prisma/client';
import type { File, Url } from '@prisma/client';
import { bytesToHuman } from './bytes';
import Logger from 'lib/logger';
import type { UserExtended } from 'middleware/withZipline';
export type ParseValue = {
file?: Omit<Partial<File>, 'password'>;
url?: Url;
user?: User;
user?: Partial<UserExtended>;
link?: string;
raw_link?: string;

View file

@ -27,7 +27,7 @@ export default function EmbeddedFile({
};
thumbnail?: Pick<Thumbnail, 'name'>;
};
user: UserExtended;
user: Omit<UserExtended, 'password' | 'secret' | 'totpSecret' | 'ratelimit'>;
prismRender: boolean;
host: string;
mediaType: 'image' | 'video' | 'audio' | 'other';
@ -35,20 +35,26 @@ export default function EmbeddedFile({
const router = useRouter();
const {
password: provPassword,
compress,
embed,
compress = 'false',
embed = 'false',
} = router.query as {
password?: string;
compress?: string;
embed?: string;
};
const dataURL = (route: string, useThumb?: boolean, withoutHost?: boolean, pass?: string) =>
const dataURL = (
route: string,
useThumb?: boolean,
withoutHost?: boolean,
pass?: string,
forcedl?: boolean,
) =>
`${withoutHost ? '' : host}${route}/${encodeURIComponent(
(useThumb && !!file.thumbnail && file.thumbnail.name) || file.name,
)}?compress=${compress?.toLowerCase() === 'true' || false}${
)}${compress.match(/^true/i) ? '?compress=true' : '?compress=false'}${
!!pass ? `&password=${encodeURIComponent(pass)}` : ''
}`;
}${forcedl ? '&download=true' : ''}`;
const [opened, setOpened] = useState(file.password);
const [password, setPassword] = useState(provPassword || '');
const [error, setError] = useState('');
@ -58,7 +64,7 @@ export default function EmbeddedFile({
file.createdAt = new Date(file ? file.createdAt : 0);
const check = async () => {
const res = await fetch(dataURL('/r'));
const res = await fetch(dataURL('/r', false, true, password));
if (res.ok) {
setError('');
@ -73,10 +79,7 @@ export default function EmbeddedFile({
const updateMedia: (url?: string) => void = function (url?: string) {
if (mediaType === 'other') return;
const mediaContent = document.getElementById(`${mediaType}_content`) as
| HTMLImageElement
| HTMLVideoElement
| HTMLAudioElement;
const mediaContent = document.getElementById(`${mediaType}_content`) as HTMLMediaElement;
if (document.head.getElementsByClassName('dynamic').length === 0) {
const metas: HTMLMetaElement[][] = [];
@ -150,40 +153,34 @@ export default function EmbeddedFile({
return (
<>
<Head>
<meta
property='og:url'
content={dataURL(router.asPath.replace(('/' + router.query['id']) as string, ''))}
/>
{!embed && !file.embed && (
{!embed.match(/^true/i) && !file.embed && mediaType === 'image' && (
<link rel='alternate' type='application/json+oembed' href={`${host}/api/oembed/${file.id}`} />
)}
{user.embed.title && file.embed && (
<meta property='og:title' content={parseString(user.embed.title, { file: file, user })} />
<meta property='og:title' content={parseString(user.embed.title, { file, user })} />
)}
{user.embed.description && file.embed && (
<meta
property='og:description'
content={parseString(user.embed.description, { file: file, user })}
/>
<meta property='og:description' content={parseString(user.embed.description, { file, user })} />
)}
{user.embed.siteName && file.embed && (
<meta property='og:site_name' content={parseString(user.embed.siteName, { file: file, user })} />
<meta property='og:site_name' content={parseString(user.embed.siteName, { file, user })} />
)}
{user.embed.color && file.embed && (
<meta property='theme-color' content={parseString(user.embed.color, { file: file, user })} />
<meta property='theme-color' content={parseString(user.embed.color, { file, user })} />
)}
{embed?.toLowerCase() === 'true' && !file.embed && (
{(embed.match(/^true/i) || file.embed) && (
<>
<meta name='og:title' content={file.name} />
<meta property='twitter:title' content={file.name} />
{mediaType === 'image' && <meta property='twitter:card' content='summary_large_image' />}
{mediaType === 'image' && (
<meta name='twitter:image' content={dataURL('/r', false, false, password)} />
)}
{mediaType === 'image' && <meta property='twitter:card' content='summary_large_image' />}
</>
)}
{mediaType === 'image' && (
<>
<meta property='og:type' content='image' />
<meta property='og:image' itemProp='image' content={dataURL('/r', false, false, password)} />
<meta property='og:image:secure_url' content={dataURL('/r', false, false, password)} />
<meta property='og:image:alt' content={file.name} />
@ -193,44 +190,27 @@ export default function EmbeddedFile({
{mediaType === 'video' && [
...(!!file.thumbnail
? [
<meta key={1} property='og:image' content={dataURL('/r', true, false, password)} />,
<meta
property='og:image:url'
key='og:image:url'
content={dataURL('/r', true, false, password)}
/>,
<meta
key={2}
property='og:image:secure_url'
key='og:image:secure_url'
content={dataURL('/r', true)}
/>,
<meta property='og:image:type' key='og:image:type' content='image/jpeg' />,
<meta
name='twitter:image'
key='twitter:image'
content={dataURL('/r', true, false, password)}
/>,
<meta
key={3}
property='og:image:type'
content={file.thumbnail.name.split('.').pop() === 'jpg' ? 'image/jpg' : 'image/gif'}
/>,
]
: []),
<meta name='twitter:card' key='twitter:card' content='player' />,
<meta name='twitter:player' key='twitter:player' content={dataURL('/r', false, false, password)} />,
<meta
name='twitter:player:stream'
key='twitter:player:stream'
content={dataURL('/r', false, false, password)}
/>,
<meta
name='twitter:player:stream:content_type'
key='twitter:player:stream:content_type'
content={file.mimetype}
/>,
<meta property='og:type' key='og:type' content='video.other' />,
<meta property='og:video' key='og:video' content={dataURL('/r', false, false, password)} />,
<meta
property='og:video:secure_url'
key='og:video:secure_url'
content={dataURL('/r', false, false, password)}
/>,
<meta property='og:video:type' key='og:video:type' content={file.mimetype} />,
<meta key={4} property='og:type' content='video.other' />,
<meta key={5} property='og:video:url' content={dataURL('/r', false, false, password)} />,
<meta key={6} property='og:video:secure_url' content={dataURL('/r', false, false, password)} />,
<meta key={7} property='og:video:type' content={file.mimetype} />,
<meta key={8} name='twitter:card' content='player' />,
<meta key={9} name='twitter:player' content={dataURL('/r', false, false, password)} />,
<meta key={10} name='twitter:player:stream' content={dataURL('/r', false, false, password)} />,
<meta key={11} name='twitter:player:stream:content_type' content={file.mimetype} />,
]}
{mediaType === 'audio' && (
<>
@ -340,12 +320,16 @@ export default function EmbeddedFile({
maxHeight: '100vh',
maxWidth: '100vw',
}}
src={dataURL('/r', false, true, password)}
controls
autoPlay
muted
poster={dataURL('/r', true, true, password)}
id='video_content'
/>
>
<source src={dataURL('/r', false, true, password)} />
<AnchorNext component={Link} href={dataURL('/r', false, true, password, true)}>
Can&#39;t preview this file. Click here to download it.
</AnchorNext>
</video>
)}
{mediaType === 'audio' && (
@ -353,7 +337,7 @@ export default function EmbeddedFile({
)}
{mediaType === 'other' && (
<AnchorNext component={Link} href={dataURL('/r', false, true, password)}>
<AnchorNext component={Link} href={dataURL('/r', false, true, password, true)}>
Can&#39;t preview this file. Click here to download it.
</AnchorNext>
)}

View file

@ -3,10 +3,10 @@ import { guess } from 'lib/mimes';
import { extname } from 'path';
import fastifyPlugin from 'fastify-plugin';
import { createBrotliCompress, createDeflate, createGzip } from 'zlib';
import pump from 'pump';
import { Transform } from 'stream';
import { parseRange } from 'lib/utils/range';
import type { File, Thumbnail } from '@prisma/client';
import { pipeline } from 'stream/promises';
function rawFileDecorator(fastify: FastifyInstance, _, done) {
fastify.decorateReply('rawFile', rawFile);
@ -18,12 +18,15 @@ function rawFileDecorator(fastify: FastifyInstance, _, done) {
filename = isThumb ? file.thumbnail?.name : file.name,
fileMime = isThumb ? null : file.mimetype;
const logger = this.server.logger.child('rawRoute');
const size = await this.server.datasource.size(filename);
if (size === null) return this.notFound();
const mimetype = await guess(extname(filename).slice(1));
if (this.request.headers.range) {
if (this.request.headers.range && !compress?.match(/^true$/i)) {
logger.debug('responding raw file with ranged');
const [start, end] = parseRange(this.request.headers.range, size);
if (start >= size || end >= size) {
const buf = await datasource.get(filename);
@ -61,14 +64,18 @@ function rawFileDecorator(fastify: FastifyInstance, _, done) {
if (
this.server.config.core.compression.enabled &&
(compress?.match(/^true$/i) || !this.request.headers['X-Zipline-NoCompress']) &&
!!this.request.headers['accept-encoding']
)
if (
size > this.server.config.core.compression.threshold &&
(fileMime || mimetype).match(/^(image(?!\/(webp))|vfileeo(?!\/(webm))|text)/)
)
return this.send(useCompress.call(this, data));
compress?.match(/^true$/i) &&
!this.request.headers['X-Zipline-NoCompress'] &&
!!this.request.headers['accept-encoding'] &&
size > this.server.config.core.compression.threshold &&
(fileMime || mimetype).match(/^(image(?!\/(webp))|video|text)/)
) {
logger.debug('responding raw file with compressed');
this.hijack();
return await useCompress.call(this, data);
}
logger.debug('responding raw file with full size');
return this.type(mimetype || 'application/octet-stream')
.headers({
@ -83,32 +90,33 @@ function rawFileDecorator(fastify: FastifyInstance, _, done) {
}
}
function useCompress(this: FastifyReply, data: NodeJS.ReadableStream) {
async function useCompress(this: FastifyReply, data: NodeJS.ReadableStream) {
let compress: Transform;
switch ((this.request.headers['accept-encoding'] as string).split(', ')[0]) {
case 'gzip':
case 'x-gzip':
compress = createGzip();
this.header('Content-Encoding', 'gzip');
this.raw.writeHead(200, { 'Content-Encoding': 'gzip' });
break;
case 'deflate':
compress = createDeflate();
this.header('Content-Encoding', 'deflate');
this.raw.writeHead(200, { 'Content-Encoding': 'deflate' });
break;
case 'br':
compress = createBrotliCompress();
this.header('Content-Encoding', 'br');
this.raw.writeHead(200, { 'Content-Encoding': 'br' });
break;
default:
this.server.logger
.child('response')
.error(`Unsupported encoding: ${this.request.headers['accept-encoding']}}`);
.debug(`Unsupported supplied encoding: ${this.request.headers['accept-encoding']}`);
this.raw.writeHead(200, {});
break;
}
if (!compress) return data;
setTimeout(() => compress.destroy(), 2000);
return pump(data, compress, (err) => (err ? this.server.logger.error(err) : null));
if (!compress) return await pipeline(data, this.raw);
return await pipeline(data, compress, this.raw);
}
export default fastifyPlugin(rawFileDecorator, {

View file

@ -80,12 +80,12 @@ async function start() {
done();
});
server.addHook('onResponse', (req, reply, done) => {
server.addHook('onRequest', (req, reply, done) => {
if (config.core.logger) {
if (req.url.startsWith('/_next')) return done();
server.logger.child('response').info(`${req.method} ${req.url} -> ${reply.statusCode}`);
server.logger.child('response').debug(
server.logger.child('request').info(`${req.method} ${req.url} -> ${reply.statusCode}`);
server.logger.child('request').debug(
JSON.stringify({
method: req.method,
url: req.url,

View file

@ -1,5 +1,5 @@
import { type File, PrismaClient, type Thumbnail } from '@prisma/client';
import { spawn } from 'child_process';
import { type ChildProcess, spawn } from 'child_process';
import ffmpeg from 'ffmpeg-static';
import { createWriteStream } from 'fs';
import { rm } from 'fs/promises';
@ -25,12 +25,58 @@ if (isMainThread) {
process.exit(1);
}
async function loadThumbnail(path) {
const args = ['-i', path, '-frames:v', '1', '-f', 'mjpeg', 'pipe:1'];
async function getDuration(path): Promise<number> {
const args = ['-hide_banner', '-nostdin', '-i', path, '-f', 'null', 'pipe:1'];
const lengthMatch = new RegExp(/time=(?<time>(\d{2,}:){2}\d{2}\.\d{2})/);
const child = spawn(ffmpeg, args, { stdio: ['ignore', 'pipe', 'pipe'] });
const data: Buffer = await new Promise((resolve, reject) => {
const data: string = await new Promise((resolve, reject) => {
const buffers: string[] = [];
child.stderr.on('data', (d) => child.stdout.emit('data', d));
child.stdout.on('data', (d) => buffers.push(d.toString()));
child.once('error', (...a) => {
console.log(a);
reject();
});
child.once('close', (code) => {
if (code !== 0) {
const msg = buffers.join('').trim().split('\n');
logger.debug(`cmd: ${ffmpeg} ${args.join(' ')}\n${msg.join('\n')}`);
logger.error(`child exited with code ${code}: ${msg[msg.length - 1]}`);
if (msg[msg.length - 1].includes('does not contain any stream')) {
// mismatched mimetype, for example a video/ogg (.ogg) file with no video stream since
// for this specific case just set the mimetype to audio/ogg
// the method will return an empty buffer since there is no video stream
logger.error(`file ${path} does not contain any video stream, it is probably an audio file`);
resolve('ow');
}
reject(new Error(`child exited with code ${code} ffmpeg output:\n${msg.join('\n')}`));
} else {
const trimBuffs: string[] = buffers.filter((val) => lengthMatch.exec(val));
resolve(trimBuffs[trimBuffs.length - 1].split('\n')[0]);
}
});
});
const matchLength = lengthMatch.exec(data);
if (!matchLength) return 0;
const timeArr = matchLength.groups.time.split(':');
return parseFloat(timeArr.reduce((prev, curr) => (parseFloat(prev) * 60 + parseFloat(curr)).toString()));
}
async function handleChild(child: ChildProcess, path: string, args: string[]): Promise<Buffer> {
return await new Promise((resolve, reject) => {
const buffers = [];
const errorBuffers = [];
@ -78,6 +124,47 @@ async function loadThumbnail(path) {
}
});
});
}
async function loadGifThumbnail(path): Promise<Buffer> {
if (!config.features.gif_thumbnails) return;
const duration = await getDuration(path);
if (duration <= 5) return;
let start: number = duration;
const re = () => (start = Math.floor(Math.random() * duration * 100) / 100);
while (start + 3 >= duration) re();
const args = [
'-i',
path,
'-ss',
start.toString(),
'-t',
'3',
'-vf',
'fps=10,scale=320:-1:flags=lanczos,split[s0][s1];[s0]palettegen=stats_mode=diff[p];[s1][p]paletteuse',
'-loop',
'0',
'-f',
'gif',
'pipe:1',
];
const child = spawn(ffmpeg, args, { stdio: ['ignore', 'pipe', 'pipe'] });
const data: Buffer = await handleChild(child, path, args);
return data;
}
async function loadThumbnail(path): Promise<Buffer> {
const args = ['-i', path, '-frames:v', '1', '-f', 'mjpeg', 'pipe:1'];
const child = spawn(ffmpeg, args, { stdio: ['ignore', 'pipe', 'pipe'] });
const data: Buffer = await handleChild(child, path, args);
return data;
}
@ -86,7 +173,10 @@ async function loadFileTmp(file: File) {
const stream = await datasource.get(file.name);
// pipe to tmp file
const tmpFile = join(config.core.temp_directory, `zipline_thumb_${file.id}_${file.id}.tmp`);
const tmpFile = join(
config.core.temp_directory,
`zipline_thumb_${file.id}_${file.mimetype.replace('/', '_')}.tmp`,
);
const fileWriteStream = createWriteStream(tmpFile);
await new Promise((resolve, reject) => {
@ -105,18 +195,23 @@ async function start() {
const file = videos[i];
if (!file.mimetype.startsWith('video/')) {
logger.info('file is not a video');
process.exit(0);
continue;
}
if (file.thumbnail) {
logger.info('thumbnail already exists');
process.exit(0);
continue;
}
const tmpFile = await loadFileTmp(file);
logger.debug(`loaded file to tmp: ${tmpFile}`);
const thumbnail = await loadThumbnail(tmpFile);
logger.debug(`loaded thumbnail: ${thumbnail.length} bytes mjpeg`);
let useStill = false,
thumbnail: Buffer = await loadGifThumbnail(tmpFile);
if (!thumbnail) {
useStill = true;
thumbnail = await loadThumbnail(tmpFile);
}
logger.debug(`loaded thumbnail: ${thumbnail.length} bytes ${useStill ? 'mjpeg' : 'gif'}`);
if (thumbnail.length === 0 && file.mimetype === 'video/ogg') {
logger.info('file might be an audio file, setting mimetype to audio/ogg to avoid future errors');
@ -141,7 +236,7 @@ async function start() {
data: {
thumbnail: {
create: {
name: `.thumb-${file.id}.jpg`,
name: `.thumb-${file.id}.${useStill ? 'jpg' : 'gif'}`,
},
},
},
@ -150,7 +245,7 @@ async function start() {
},
});
await datasource.save(thumb.name, thumbnail, { type: 'image/jpeg' });
await datasource.save(thumb.name, thumbnail, { type: useStill ? 'image/jpeg' : 'image/gif' });
logger.info(`thumbnail saved - ${thumb.name}`);
logger.debug(`thumbnail ${JSON.stringify(thumb)}`);