1
Fork 0
mirror of https://github.com/diced/zipline.git synced 2025-04-04 23:21:17 -05:00

fix: a lot of stuff (#683)

* fix: No more infinite loading button! :D

* chore: buhbai version!

* chore: update browserlist db

* fix: a totp secret that shouldn't be /probably/ shouldn't be revealed

* fix: revert range getting for datasource

* chore: a line lost! :O

* chore: this probably should've been ignored for a long while

* fix: Don't compress webm or webp. They go breaky

* fix: issue 659, it was the wrong statusCode to look for

* fix: I'll just regex it.

* fix: let s3 in on the fun with partial uploads

* chore&fix: they're files now :3 & unlock video and/or audio files

* fix: Maybe prisma plugin needs a return?

* fix: super focused regex this time :D

* I guess this works? So cool :O

* fix: bad id check

* fix: Byte me! >:3

* fix: add password bool to file's prop

* fix(?): this might fix some people's weard errors.

* chore: I discovered more typing

* fix: stats logger

* fix(?): await the registers

* chore: typeer typer

* fix: This looks to properly fix issue 659. I dunno how, don't ask

* More like uglier >:(

* fix: actions don't like dis

* fix: ranged requests handled properly

* feat: remove supabase datasource

---------

Co-authored-by: diced <pranaco2@gmail.com>
This commit is contained in:
Jay 2025-02-03 12:00:49 -08:00 committed by GitHub
parent 41e197ed4a
commit 1febd5aca0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 299 additions and 373 deletions

View file

@ -1,7 +1,7 @@
# every field in here is optional except, CORE_SECRET and CORE_DATABASE_URL.
# if CORE_SECRET is still "changethis" then zipline will exit and tell you to change it.
# if using s3/supabase make sure to uncomment or comment out the correct lines needed.
# if using s3 make sure to uncomment or comment out the correct lines needed.
CORE_RETURN_HTTPS=true
CORE_SECRET="changethis"
@ -27,13 +27,6 @@ DATASOURCE_LOCAL_DIRECTORY=./uploads
# DATASOURCE_S3_FORCE_S3_PATH=false
# DATASOURCE_S3_USE_SSL=false
# or supabase
# DATASOURCE_TYPE=supabase
# DATASOURCE_SUPABASE_KEY=xxx
# remember: no leading slash
# DATASOURCE_SUPABASE_URL=https://something.supabase.co
# DATASOURCE_SUPABASE_BUCKET=zipline
UPLOADER_DEFAULT_FORMAT=RANDOM
UPLOADER_ROUTE=/u
UPLOADER_LENGTH=6

1
.gitignore vendored
View file

@ -31,6 +31,7 @@ yarn-debug.log*
yarn-error.log*
# local env files
.env
.env.local
.env.development.local
.env.test.local

View file

@ -1,4 +1,3 @@
version: '3'
services:
postgres:
image: postgres:15

View file

@ -1,4 +1,3 @@
version: '3'
services:
postgres:
image: postgres:15

View file

@ -1,5 +1,5 @@
import { ActionIcon, Button, Center, Group, SimpleGrid, Title } from '@mantine/core';
import { File } from '@prisma/client';
import type { File } from '@prisma/client';
import { IconArrowLeft, IconFile } from '@tabler/icons-react';
import FileComponent from 'components/File';
import MutedText from 'components/MutedText';

View file

@ -20,10 +20,9 @@ export interface ConfigCompression {
}
export interface ConfigDatasource {
type: 'local' | 's3' | 'supabase';
type: 'local' | 's3';
local: ConfigLocalDatasource;
s3?: ConfigS3Datasource;
supabase?: ConfigSupabaseDatasource;
}
export interface ConfigLocalDatasource {
@ -41,12 +40,6 @@ export interface ConfigS3Datasource {
region?: string;
}
export interface ConfigSupabaseDatasource {
url: string;
key: string;
bucket: string;
}
export interface ConfigUploader {
default_format: string;
route: string;

View file

@ -85,10 +85,6 @@ export default function readConfig() {
map('DATASOURCE_S3_REGION', 'string', 'datasource.s3.region'),
map('DATASOURCE_S3_USE_SSL', 'boolean', 'datasource.s3.use_ssl'),
map('DATASOURCE_SUPABASE_URL', 'string', 'datasource.supabase.url'),
map('DATASOURCE_SUPABASE_KEY', 'string', 'datasource.supabase.key'),
map('DATASOURCE_SUPABASE_BUCKET', 'string', 'datasource.supabase.bucket'),
map('UPLOADER_DEFAULT_FORMAT', 'string', 'uploader.default_format'),
map('UPLOADER_ROUTE', 'string', 'uploader.route'),
map('UPLOADER_LENGTH', 'number', 'uploader.length'),

View file

@ -51,7 +51,7 @@ const validator = s.object({
}),
datasource: s
.object({
type: s.enum('local', 's3', 'supabase').default('local'),
type: s.enum('local', 's3').default('local'),
local: s
.object({
directory: s.string.default(resolve('./uploads')).transform((v) => resolve(v)),
@ -69,11 +69,6 @@ const validator = s.object({
region: s.string.default('us-east-1'),
use_ssl: s.boolean.default(false),
}).optional,
supabase: s.object({
url: s.string,
key: s.string,
bucket: s.string,
}).optional,
})
.default({
type: 'local',
@ -253,43 +248,29 @@ export default function validate(config): Config {
logger.debug(`Attemping to validate ${JSON.stringify(config)}`);
const validated = validator.parse(config);
logger.debug(`Recieved config: ${JSON.stringify(validated)}`);
switch (validated.datasource.type) {
case 's3': {
const errors = [];
if (!validated.datasource.s3.access_key_id)
errors.push('datasource.s3.access_key_id is a required field');
if (!validated.datasource.s3.secret_access_key)
errors.push('datasource.s3.secret_access_key is a required field');
if (!validated.datasource.s3.bucket) errors.push('datasource.s3.bucket is a required field');
if (!validated.datasource.s3.endpoint) errors.push('datasource.s3.endpoint is a required field');
if (errors.length) throw { errors };
break;
}
case 'supabase': {
const errors = [];
if (!validated.datasource.supabase.key) errors.push('datasource.supabase.key is a required field');
if (!validated.datasource.supabase.url) errors.push('datasource.supabase.url is a required field');
if (!validated.datasource.supabase.bucket)
errors.push('datasource.supabase.bucket is a required field');
if (errors.length) throw { errors };
break;
}
if (validated.datasource.type === 's3') {
const errors = [];
if (!validated.datasource.s3.access_key_id)
errors.push('datasource.s3.access_key_id is a required field');
if (!validated.datasource.s3.secret_access_key)
errors.push('datasource.s3.secret_access_key is a required field');
if (!validated.datasource.s3.bucket) errors.push('datasource.s3.bucket is a required field');
if (!validated.datasource.s3.endpoint) errors.push('datasource.s3.endpoint is a required field');
if (errors.length) throw { errors };
}
const reserved = ['/view', '/dashboard', '/code', '/folder', '/api', '/auth', '/r'];
if (reserved.some((r) => validated.uploader.route.startsWith(r))) {
const reserved = new RegExp(/^\/(view|code|folder|auth|r)(\/\S*)?$|^\/(api|dashboard)(\/\S*)*/);
if (reserved.exec(validated.uploader.route))
throw {
errors: [`The uploader route cannot be ${validated.uploader.route}, this is a reserved route.`],
show: true,
};
} else if (reserved.some((r) => validated.urls.route.startsWith(r))) {
if (reserved.exec(validated.urls.route))
throw {
errors: [`The urls route cannot be ${validated.urls.route}, this is a reserved route.`],
show: true,
};
}
return validated as unknown as Config;
} catch (e) {

View file

@ -1,5 +1,5 @@
import config from './config';
import { Datasource, Local, S3, Supabase } from './datasources';
import { Datasource, Local, S3 } from './datasources';
import Logger from './logger';
const logger = Logger.get('datasource');
@ -14,10 +14,6 @@ if (!global.datasource) {
global.datasource = new Local(config.datasource.local.directory);
logger.info(`using Local(${config.datasource.local.directory}) datasource`);
break;
case 'supabase':
global.datasource = new Supabase(config.datasource.supabase);
logger.info(`using Supabase(${config.datasource.supabase.bucket}) datasource`);
break;
default:
throw new Error('Invalid datasource type');
}

View file

@ -7,6 +7,7 @@ export abstract class Datasource {
public abstract delete(file: string): Promise<void>;
public abstract clear(): Promise<void>;
public abstract size(file: string): Promise<number | null>;
public abstract get(file: string, start?: number, end?: number): Readable | Promise<Readable>;
public abstract get(file: string): Readable | Promise<Readable>;
public abstract fullSize(): Promise<number>;
public abstract range(file: string, start: number, end: number): Promise<Readable>;
}

View file

@ -11,7 +11,7 @@ export class Local extends Datasource {
}
public async save(file: string, data: Buffer): Promise<void> {
await writeFile(join(this.path, file), data);
await writeFile(join(this.path, file), Uint8Array.from(data));
}
public async delete(file: string): Promise<void> {
@ -26,12 +26,12 @@ export class Local extends Datasource {
}
}
public get(file: string, start: number = 0, end: number = Infinity): ReadStream {
public get(file: string): ReadStream {
const full = join(this.path, file);
if (!existsSync(full)) return null;
try {
return createReadStream(full, { start, end });
return createReadStream(full);
} catch (e) {
return null;
}
@ -56,4 +56,11 @@ export class Local extends Datasource {
return size;
}
public async range(file: string, start: number, end: number): Promise<ReadStream> {
const path = join(this.path, file);
const readStream = createReadStream(path, { start, end });
return readStream;
}
}

View file

@ -1,5 +1,5 @@
import { Datasource } from '.';
import { Readable } from 'stream';
import { PassThrough, Readable } from 'stream';
import { ConfigS3Datasource } from 'lib/config/Config';
import { BucketItemStat, Client } from 'minio';
@ -24,7 +24,8 @@ export class S3 extends Datasource {
await this.s3.putObject(
this.config.bucket,
file,
data,
new PassThrough().end(data),
data.byteLength,
options ? { 'Content-Type': options.type } : undefined,
);
}
@ -45,28 +46,12 @@ export class S3 extends Datasource {
});
}
public get(file: string, start: number = 0, end: number = Infinity): Promise<Readable> {
if (start === 0 && end === Infinity) {
return new Promise((res) => {
this.s3.getObject(this.config.bucket, file, (err, stream) => {
if (err) res(null);
else res(stream);
});
});
}
public get(file: string): Promise<Readable> {
return new Promise((res) => {
this.s3.getPartialObject(
this.config.bucket,
file,
start,
// undefined means to read the rest of the file from the start (offset)
end === Infinity ? undefined : end,
(err, stream) => {
if (err) res(null);
else res(stream);
},
);
this.s3.getObject(this.config.bucket, file, (err, stream) => {
if (err) res(null);
else res(stream);
});
});
}
@ -96,4 +81,15 @@ export class S3 extends Datasource {
});
});
}
public async range(file: string, start: number, end: number): Promise<Readable> {
return new Promise((res) => {
this.s3.getPartialObject(this.config.bucket, file, start, end, (err, stream) => {
if (err) {
console.log(err);
res(null);
} else res(stream);
});
});
}
}

View file

@ -1,141 +0,0 @@
import { Datasource } from '.';
import { ConfigSupabaseDatasource } from 'lib/config/Config';
import { guess } from 'lib/mimes';
import Logger from 'lib/logger';
import { Readable } from 'stream';
export class Supabase extends Datasource {
public name = 'Supabase';
public logger: Logger = Logger.get('datasource::supabase');
public constructor(public config: ConfigSupabaseDatasource) {
super();
}
public async save(file: string, data: Buffer): Promise<void> {
const mimetype = await guess(file.split('.').pop());
const r = await fetch(`${this.config.url}/storage/v1/object/${this.config.bucket}/${file}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.config.key}`,
'Content-Type': mimetype,
},
body: data,
});
const j = await r.json();
if (j.error) this.logger.error(`${j.error}: ${j.message}`);
}
public async delete(file: string): Promise<void> {
await fetch(`${this.config.url}/storage/v1/object/${this.config.bucket}/${file}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${this.config.key}`,
},
});
}
public async clear(): Promise<void> {
try {
const resp = await fetch(`${this.config.url}/storage/v1/object/list/${this.config.bucket}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.config.key}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
prefix: '',
}),
});
const objs = await resp.json();
if (objs.error) throw new Error(`${objs.error}: ${objs.message}`);
const res = await fetch(`${this.config.url}/storage/v1/object/${this.config.bucket}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${this.config.key}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
prefixes: objs.map((x: { name: string }) => x.name),
}),
});
const j = await res.json();
if (j.error) throw new Error(`${j.error}: ${j.message}`);
return;
} catch (e) {
this.logger.error(e);
}
}
public async get(file: string, start: number = 0, end: number = Infinity): Promise<Readable> {
// get a readable stream from the request
const r = await fetch(`${this.config.url}/storage/v1/object/${this.config.bucket}/${file}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${this.config.key}`,
Range: `bytes=${start}-${end === Infinity ? '' : end}`,
},
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return Readable.fromWeb(r.body as any);
}
public size(file: string): Promise<number | null> {
return new Promise(async (res) => {
fetch(`${this.config.url}/storage/v1/object/list/${this.config.bucket}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.config.key}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
prefix: '',
search: file,
}),
})
.then((r) => r.json())
.then((j) => {
if (j.error) {
this.logger.error(`${j.error}: ${j.message}`);
res(null);
}
if (j.length === 0) {
res(null);
} else {
res(j[0].metadata.size);
}
});
});
}
public async fullSize(): Promise<number> {
return new Promise((res) => {
fetch(`${this.config.url}/storage/v1/object/list/${this.config.bucket}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.config.key}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
prefix: '',
}),
})
.then((r) => r.json())
.then((j) => {
if (j.error) {
this.logger.error(`${j.error}: ${j.message}`);
res(0);
}
res(j.reduce((a, b) => a + b.metadata.size, 0));
});
});
}
}

View file

@ -1,4 +1,3 @@
export { Datasource } from './Datasource';
export { Local } from './Local';
export { S3 } from './S3';
export { Supabase } from './Supabase';

View file

@ -1,4 +1,4 @@
import { File, Url, User } from '@prisma/client';
import type { File, Url, User } from '@prisma/client';
import config from 'lib/config';
import { ConfigDiscordContent } from 'config/Config';
import Logger from 'lib/logger';

View file

@ -1,7 +1,11 @@
import { PrismaClient } from '@prisma/client';
import 'lib/config';
if (!global.prisma) {
if (!process.env.ZIPLINE_DOCKER_BUILD) global.prisma = new PrismaClient();
if (!process.env.ZIPLINE_DOCKER_BUILD) {
process.env.DATABASE_URL = config.core.database_url;
global.prisma = new PrismaClient();
}
}
export default global.prisma as PrismaClient;

View file

@ -1,4 +1,4 @@
import { InvisibleFile, InvisibleUrl } from '@prisma/client';
import type { InvisibleFile, InvisibleUrl } from '@prisma/client';
import { hash, verify } from 'argon2';
import { randomBytes } from 'crypto';
import { readdir, stat } from 'fs/promises';

View file

@ -1,4 +1,4 @@
import { File } from '@prisma/client';
import type { File } from '@prisma/client';
import { ExifTool, Tags } from 'exiftool-vendored';
import { createWriteStream } from 'fs';
import { readFile, rm } from 'fs/promises';

View file

@ -1,9 +1,20 @@
export function parseRangeHeader(header?: string): [number, number] {
if (!header || !header.startsWith('bytes=')) return [0, Infinity];
export function parseRange(header: string, length: number): [number, number] {
const range = header.trim().substring(6);
const range = header.replace('bytes=', '').split('-');
const start = Number(range[0]) || 0;
const end = Number(range[1]) || Infinity;
let start, end;
if (range.startsWith('-')) {
end = length - 1;
start = length - 1 - Number(range.substring(1));
} else {
const [s, e] = range.split('-').map(Number);
start = s;
end = e || length - 1;
}
if (end > length - 1) {
end = length - 1;
}
return [start, end];
}

View file

@ -15,7 +15,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
},
});
if (!file) return res.notFound('image not found');
if (!file) return res.notFound('file not found');
if (!password) return res.badRequest('no password provided');
const decoded = decodeURIComponent(password as string);
@ -24,7 +24,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
if (!valid) return res.badRequest('wrong password');
const data = await datasource.get(file.name);
if (!data) return res.notFound('image not found');
if (!data) return res.notFound('file not found');
const size = await datasource.size(file.name);
@ -33,7 +33,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
res.setHeader('Content-Length', size);
data.pipe(res);
data.on('error', () => res.notFound('image not found'));
data.on('error', () => res.notFound('file not found'));
data.on('end', () => res.end());
}

View file

@ -14,8 +14,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
code?: string;
};
const users = await prisma.user.count();
if (users === 0) {
if ((await prisma.user.count()) === 0) {
logger.debug('no users found... creating default user...');
await prisma.user.create({
data: {

View file

@ -12,9 +12,11 @@ const logger = Logger.get('user');
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
const { id } = req.query as { id: string };
if (!id || isNaN(parseInt(id))) return res.notFound('no user provided');
const target = await prisma.user.findFirst({
where: {
id: Number(id),
id: parseInt(id),
},
include: {
files: {
@ -187,6 +189,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
return res.json(newUser);
} else {
delete target.password;
delete target.totpSecret;
if (user.superAdmin && target.superAdmin) {
delete target.files;

View file

@ -142,6 +142,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
size: bigint;
originalName: string;
thumbnail?: { name: string };
password: string | boolean;
}[] = await prisma.file.findMany({
where: {
userId: user.id,
@ -163,11 +164,13 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
size: true,
originalName: true,
thumbnail: true,
password: true,
},
});
for (let i = 0; i !== files.length; ++i) {
(files[i] as unknown as { url: string }).url = formatRootUrl(config.uploader.route, files[i].name);
files[i].password = !!files[i].password;
if (files[i].thumbnail) {
(files[i].thumbnail as unknown as string) = formatRootUrl('/r', files[i].thumbnail.name);

View file

@ -8,7 +8,20 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
if (take >= 50) return res.badRequest("take can't be more than 50");
let files = await prisma.file.findMany({
let files: {
favorite: boolean;
createdAt: Date;
id: number;
name: string;
mimetype: string;
expiresAt: Date;
maxViews: number;
views: number;
folderId: number;
size: bigint;
password: string | boolean;
thumbnail?: { name: string };
}[] = await prisma.file.findMany({
take,
where: {
userId: user.id,
@ -28,14 +41,16 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
size: true,
favorite: true,
thumbnail: true,
password: true,
},
});
for (let i = 0; i !== files.length; ++i) {
(files[i] as unknown as { url: string }).url = formatRootUrl(config.uploader.route, files[i].name);
if (files[i].thumbnail) {
files[i].password = !!files[i].password;
if (files[i].thumbnail)
(files[i].thumbnail as unknown as string) = formatRootUrl('/r', files[i].thumbnail.name);
}
}
if (req.query.filter && req.query.filter === 'media')

View file

@ -67,7 +67,11 @@ export default function Login({
const username = values.username.trim();
const password = values.password.trim();
if (username === '') return form.setFieldError('username', "Username can't be nothing");
if (username === '') {
setLoading(false);
setDisabled(false);
return form.setFieldError('username', "Username can't be nothing");
}
const res = await useFetch('/api/auth/login', 'POST', {
username,

View file

@ -40,16 +40,18 @@ export default function EmbeddedFile({
const [downloadWPass, setDownloadWPass] = useState(false);
const mimeMatch = new RegExp(/^((?<img>image)|(?<vid>video)|(?<aud>audio))/).exec(file.mimetype);
// reapply date from workaround
file.createdAt = new Date(file ? file.createdAt : 0);
const check = async () => {
const res = await fetch(`/api/auth/image?id=${file.id}&password=${encodeURIComponent(password)}`);
const res = await fetch(`/api/auth/file?id=${file.id}&password=${encodeURIComponent(password)}`);
if (res.ok) {
setError('');
if (prismRender) return router.push(`/code/${file.name}?password=${password}`);
updateImage(`/api/auth/image?id=${file.id}&password=${password}`);
updateFile(`/api/auth/file?id=${file.id}&password=${password}`);
setOpened(false);
setDownloadWPass(true);
} else {
@ -57,34 +59,40 @@ export default function EmbeddedFile({
}
};
const updateImage = async (url?: string) => {
if (!file.mimetype.startsWith('image')) return;
const updateFile = async (url?: string) => {
if (!mimeMatch) return;
const imageEl = document.getElementById('image_content') as HTMLImageElement;
const imageEl = document.getElementById('image_content') as HTMLImageElement,
videoEl = document.getElementById('video_content') as HTMLVideoElement,
audioEl = document.getElementById('audio_content') as HTMLAudioElement;
const img = new Image();
img.addEventListener('load', function () {
// my best attempt of recreating
// firefox: https://searchfox.org/mozilla-central/source/dom/html/ImageDocument.cpp#271-276
// chromium-based: https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/html/image_document.cc
if (mimeMatch?.groups?.img) {
const img = new Image();
img.addEventListener('load', function () {
// my best attempt of recreating
// firefox: https://searchfox.org/mozilla-central/source/dom/html/ImageDocument.cpp#271-276
// chromium-based: https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/html/image_document.cc
// keeps image original if smaller than screen
if (this.width <= window.innerWidth && this.height <= window.innerHeight) return;
// keeps image original if smaller than screen
if (this.width <= window.innerWidth && this.height <= window.innerHeight) return;
// resizes to fit screen
const ratio = Math.min(innerHeight / this.naturalHeight, innerWidth / this.naturalWidth);
const newWidth = Math.max(1, Math.floor(ratio * this.naturalWidth));
const newHeight = Math.max(1, Math.floor(ratio * this.naturalHeight));
// resizes to fit screen
const ratio = Math.min(innerHeight / this.naturalHeight, innerWidth / this.naturalWidth);
const newWidth = Math.max(1, Math.floor(ratio * this.naturalWidth));
const newHeight = Math.max(1, Math.floor(ratio * this.naturalHeight));
imageEl.width = newWidth;
imageEl.height = newHeight;
});
imageEl.width = newWidth;
imageEl.height = newHeight;
});
img.src = url || dataURL('/r');
if (url) {
imageEl.src = url;
img.src = url || dataURL('/r');
file.imageProps = img;
}
if (url) {
if (mimeMatch?.groups?.img) imageEl.src = url;
if (mimeMatch?.groups?.vid) videoEl.src = url;
if (mimeMatch?.groups?.aud) audioEl.src = url;
}
file.imageProps = img;
};
useEffect(() => {
@ -94,12 +102,12 @@ export default function EmbeddedFile({
}, []);
useEffect(() => {
if (!file?.mimetype?.startsWith('image')) return;
if (!mimeMatch) return;
updateImage();
window.addEventListener('resize', () => updateImage());
updateFile();
window.addEventListener('resize', () => updateFile());
return () => {
window.removeEventListener('resize', () => updateImage());
window.removeEventListener('resize', () => updateFile());
};
}, []);
@ -125,7 +133,7 @@ export default function EmbeddedFile({
<meta property='theme-color' content={parseString(user.embed.color, { file: file, user })} />
)}
{file.mimetype.startsWith('image') && (
{mimeMatch?.groups?.img && (
<>
<meta property='og:type' content='image' />
<meta property='og:image' itemProp='image' content={`${host}/r/${file.name}`} />
@ -137,7 +145,7 @@ export default function EmbeddedFile({
<meta property='twitter:title' content={file.name} />
</>
)}
{file.mimetype.startsWith('video') && (
{mimeMatch?.groups?.vid && (
<>
<meta name='twitter:card' content='player' />
<meta name='twitter:player' content={`${host}/r/${file.name}`} />
@ -160,7 +168,7 @@ export default function EmbeddedFile({
<meta property='og:video:type' content={file.mimetype} />
</>
)}
{file.mimetype.startsWith('audio') && (
{mimeMatch?.groups?.aud && (
<>
<meta name='twitter:card' content='player' />
<meta name='twitter:player' content={`${host}/r/${file.name}`} />
@ -177,9 +185,7 @@ export default function EmbeddedFile({
<meta property='og:audio:type' content={file.mimetype} />
</>
)}
{!file.mimetype.startsWith('video') && !file.mimetype.startsWith('image') && (
<meta property='og:url' content={`${host}/r/${file.name}`} />
)}
{!mimeMatch && <meta property='og:url' content={`${host}/r/${file.name}`} />}
<title>{file.name}</title>
</Head>
<Modal
@ -209,11 +215,9 @@ export default function EmbeddedFile({
justifyContent: 'center',
}}
>
{file.mimetype.startsWith('image') && (
<img src={dataURL('/r')} alt={dataURL('/r')} id='image_content' />
)}
{mimeMatch?.groups?.img && <img src={dataURL('/r')} alt={dataURL('/r')} id='image_content' />}
{file.mimetype.startsWith('video') && (
{mimeMatch?.groups?.vid && (
<video
style={{
maxHeight: '100vh',
@ -227,17 +231,13 @@ export default function EmbeddedFile({
/>
)}
{file.mimetype.startsWith('audio') && (
<audio src={dataURL('/r')} controls autoPlay muted id='audio_content' />
)}
{mimeMatch?.groups?.aud && <audio src={dataURL('/r')} controls autoPlay muted id='audio_content' />}
{!file.mimetype.startsWith('video') &&
!file.mimetype.startsWith('image') &&
!file.mimetype.startsWith('audio') && (
<AnchorNext component={Link} href={dataURL('/r', downloadWPass ? password : undefined)}>
Can&#39;t preview this file. Click here to download it.
</AnchorNext>
)}
{!mimeMatch && (
<AnchorNext component={Link} href={dataURL('/r', downloadWPass ? password : undefined)}>
Can&#39;t preview this file. Click here to download it.
</AnchorNext>
)}
</Box>
</>
);

View file

@ -53,8 +53,9 @@ async function main() {
// copy files to local storage
console.log(`Copying files to ${config.datasource.type} storage..`);
for (let i = 0; i !== files.length; ++i) {
const file = files[i];
await datasource.save(file, await readFile(join(directory, file)), {
const file = files[i],
fb = await readFile(join(directory, file));
await datasource.save(file, fb, {
type: data[i]?.mimetype ?? 'application/octet-stream',
});
}

View file

@ -1,4 +1,4 @@
import config from 'lib/config';
import 'lib/config';
import { inspect } from 'util';
console.log(inspect(config, { depth: Infinity, colors: true }));

View file

@ -2,7 +2,7 @@ import { File } from '@prisma/client';
import { FastifyInstance, FastifyReply } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
import exts from 'lib/exts';
import { parseRangeHeader } from 'lib/utils/range';
import { parseRange } from 'lib/utils/range';
function dbFileDecorator(fastify: FastifyInstance, _, done) {
fastify.decorateReply('dbFile', dbFile);
@ -17,28 +17,70 @@ function dbFileDecorator(fastify: FastifyInstance, _, done) {
const size = await this.server.datasource.size(file.name);
if (size === null) return this.notFound();
// eslint-disable-next-line prefer-const
let [rangeStart, rangeEnd] = parseRangeHeader(this.request.headers.range);
if (rangeStart >= rangeEnd)
return this.code(416)
.header('Content-Range', `bytes 0/${size - 1}`)
.send();
if (rangeEnd === Infinity) rangeEnd = size - 1;
const data = await this.server.datasource.get(file.name, rangeStart, rangeEnd);
// only send content-range if the client asked for it
if (this.request.headers.range) {
this.code(206);
this.header('Content-Range', `bytes ${rangeStart}-${rangeEnd}/${size}`);
const [start, end] = parseRange(this.request.headers.range, size);
if (start >= size || end >= size) {
const buf = await datasource.get(file.name);
if (!buf) return this.server.nextServer.render404(this.request.raw, this.raw);
return this.type(file.mimetype || 'application/octet-stream')
.headers({
'Content-Length': size,
...(file.originalName
? {
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(
file.originalName,
)}"`,
}
: download && {
'Content-Disposition': 'attachment;',
}),
})
.status(416)
.send(buf);
}
const buf = await datasource.range(file.name, start || 0, end);
if (!buf) return this.server.nextServer.render404(this.request.raw, this.raw);
return this.type(file.mimetype || 'application/octet-stream')
.headers({
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
...(file.originalName
? {
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(
file.originalName,
)}"`,
}
: download && {
'Content-Disposition': 'attachment;',
}),
})
.status(206)
.send(buf);
}
this.header('Content-Length', rangeEnd - rangeStart + 1);
this.header('Content-Type', download ? 'application/octet-stream' : file.mimetype);
this.header('Content-Disposition', `inline; filename="${encodeURI(file.originalName || file.name)}"`);
this.header('Accept-Ranges', 'bytes');
const data = await datasource.get(file.name);
if (!data) return this.server.nextServer.render404(this.request.raw, this.raw);
return this.send(data);
return this.type(file.mimetype || 'application/octet-stream')
.headers({
'Content-Length': size,
'Accept-Ranges': 'bytes',
...(file.originalName
? {
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(
file.originalName,
)}"`,
}
: download && {
'Content-Disposition': 'attachment;',
}),
})
.status(200)
.send(data);
}
}

View file

@ -2,12 +2,12 @@ import { Url } from '@prisma/client';
import { FastifyInstance, FastifyReply } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
function postUrlDecorator(fastify: FastifyInstance, _, done) {
fastify.decorateReply('postUrl', postUrl.bind(fastify));
function postUrlDecorator(fastify: FastifyInstance, _, done: () => void) {
fastify.decorateReply('postUrl', postUrl);
done();
async function postUrl(this: FastifyReply, url: Url) {
if (!url) return true;
if (!url) return;
const nUrl = await this.server.prisma.url.update({
where: {
@ -27,6 +27,7 @@ function postUrlDecorator(fastify: FastifyInstance, _, done) {
this.server.logger.child('url').info(`url deleted due to max views ${JSON.stringify(nUrl)}`);
}
return;
}
}

View file

@ -5,7 +5,7 @@ import fastifyPlugin from 'fastify-plugin';
import { createBrotliCompress, createDeflate, createGzip } from 'zlib';
import pump from 'pump';
import { Transform } from 'stream';
import { parseRangeHeader } from 'lib/utils/range';
import { parseRange } from 'lib/utils/range';
function rawFileDecorator(fastify: FastifyInstance, _, done) {
fastify.decorateReply('rawFile', rawFile);
@ -18,36 +18,63 @@ function rawFileDecorator(fastify: FastifyInstance, _, done) {
const mimetype = await guess(extname(id).slice(1));
// eslint-disable-next-line prefer-const
let [rangeStart, rangeEnd] = parseRangeHeader(this.request.headers.range);
if (rangeStart >= rangeEnd)
return this.code(416)
.header('Content-Range', `bytes 0/${size - 1}`)
.send();
if (rangeEnd === Infinity) rangeEnd = size - 1;
const data = await this.server.datasource.get(id, rangeStart, rangeEnd + 1);
// only send content-range if the client asked for it
if (this.request.headers.range) {
this.code(206);
this.header('Content-Range', `bytes ${rangeStart}-${rangeEnd}/${size}`);
const [start, end] = parseRange(this.request.headers.range, size);
if (start >= size || end >= size) {
const buf = await datasource.get(id);
if (!buf) return this.server.nextServer.render404(this.request.raw, this.raw);
return this.type(mimetype || 'application/octet-stream')
.headers({
'Content-Length': size,
...(download && {
'Content-Disposition': 'attachment;',
}),
})
.status(416)
.send(buf);
}
const buf = await datasource.range(id, start || 0, end);
if (!buf) return this.server.nextServer.render404(this.request.raw, this.raw);
return this.type(mimetype || 'application/octet-stream')
.headers({
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
...(download && {
'Content-Disposition': 'attachment;',
}),
})
.status(206)
.send(buf);
}
this.header('Content-Length', rangeEnd - rangeStart + 1);
this.header('Content-Type', download ? 'application/octet-stream' : mimetype);
this.header('Accept-Ranges', 'bytes');
const data = await datasource.get(id);
if (!data) return this.server.nextServer.render404(this.request.raw, this.raw);
if (
this.server.config.core.compression.enabled &&
compress?.match(/^true$/i) &&
!this.request.headers['X-Zipline-NoCompress'] &&
(compress?.match(/^true$/i) || !this.request.headers['X-Zipline-NoCompress']) &&
!!this.request.headers['accept-encoding']
)
if (size > this.server.config.core.compression.threshold && mimetype.match(/^(image|video|text)/))
if (
size > this.server.config.core.compression.threshold &&
mimetype.match(/^(image(?!\/(webp))|video(?!\/(webm))|text)/)
)
return this.send(useCompress.call(this, data));
return this.send(data);
return this.type(mimetype || 'application/octet-stream')
.headers({
'Content-Length': size,
'Accept-Ranges': 'bytes',
...(download && {
'Content-Disposition': 'attachment;',
}),
})
.status(200)
.send(data);
}
}

View file

@ -46,7 +46,7 @@ async function start() {
logger.debug('Starting server');
// plugins
server
await server
.register(loggerPlugin)
.register(configPlugin, config)
.register(datasourcePlugin, datasource)
@ -61,7 +61,7 @@ async function start() {
.register(allPlugin);
// decorators
server
await server
.register(notFound)
.register(postUrlDecorator)
.register(postFileDecorator)

View file

@ -1,15 +1,13 @@
import { PrismaClient } from '@prisma/client';
import { FastifyInstance } from 'fastify';
import type { FastifyInstance } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
import { migrations } from 'server/util';
async function prismaPlugin(fastify: FastifyInstance) {
process.env.DATABASE_URL = fastify.config.core?.database_url;
await migrations();
const prisma = new PrismaClient();
fastify.decorate('prisma', prisma);
fastify.decorate('prisma', new PrismaClient());
return;
}
export default fastifyPlugin(prismaPlugin, {

View file

@ -7,21 +7,21 @@ export default async function uploadsRoute(this: FastifyInstance, req: FastifyRe
else if (id === 'dashboard' && !this.config.features.headless)
return this.nextServer.render(req.raw, reply.raw, '/dashboard');
const image = await this.prisma.file.findFirst({
const file = await this.prisma.file.findFirst({
where: {
OR: [{ name: id }, { name: decodeURI(id) }, { invisible: { invis: decodeURI(encodeURI(id)) } }],
},
});
if (!image) return reply.rawFile(id);
if (!file) return reply.rawFile(id);
const failed = await reply.preFile(image);
const failed = await reply.preFile(file);
if (failed) return reply.notFound();
const ext = image.name.split('.').pop();
const ext = file.name.split('.').pop();
if (image.password || image.embed || image.mimetype.startsWith('text/') || Object.keys(exts).includes(ext))
return reply.redirect(`/view/${image.name}`);
else return reply.dbFile(image);
if (file.password || file.embed || file.mimetype.startsWith('text/') || Object.keys(exts).includes(ext))
return reply.redirect(`/view/${file.name}`);
else return reply.dbFile(file);
}
export async function uploadsRouteOnResponse(

View file

@ -13,9 +13,7 @@ export default async function urlsRoute(this: FastifyInstance, req: FastifyReque
});
if (!url) return reply.notFound();
reply.redirect(url.destination);
reply.postUrl(url);
return await reply.redirect(url.destination);
}
export async function urlsRouteOnResponse(
@ -24,7 +22,7 @@ export async function urlsRouteOnResponse(
reply: FastifyReply,
done: () => void,
) {
if (reply.statusCode === 200) {
if (reply.statusCode === 302) {
const { id } = req.params as { id: string };
const url = await this.prisma.url.findFirst({
@ -32,8 +30,7 @@ export async function urlsRouteOnResponse(
OR: [{ id }, { vanity: id }, { invisible: { invis: decodeURI(id) } }],
},
});
reply.postUrl(url);
await reply.postUrl(url);
}
done();

View file

@ -83,7 +83,8 @@ export function redirect(res: ServerResponse, url: string) {
export async function getStats(prisma: PrismaClient, datasource: Datasource, logger: Logger) {
const size = await datasource.fullSize();
logger.debug(`full size: ${size}`);
const llogger = logger.child('stats');
llogger.debug(`full size: ${size}`);
const byUser = await prisma.file.groupBy({
by: ['userId'],
@ -91,15 +92,15 @@ export async function getStats(prisma: PrismaClient, datasource: Datasource, log
_all: true,
},
});
logger.debug(`by user: ${JSON.stringify(byUser)}`);
llogger.debug(`by user: ${JSON.stringify(byUser)}`);
const count_users = await prisma.user.count();
logger.debug(`count users: ${count_users}`);
llogger.debug(`count users: ${count_users}`);
const count_by_user = [];
for (let i = 0, L = byUser.length; i !== L; ++i) {
if (!byUser[i].userId) {
logger.debug(`skipping user ${byUser[i]}`);
llogger.debug(`skipping user ${byUser[i]}`);
continue;
}
@ -114,17 +115,17 @@ export async function getStats(prisma: PrismaClient, datasource: Datasource, log
count: byUser[i]._count._all,
});
}
logger.debug(`count by user: ${JSON.stringify(count_by_user)}`);
llogger.debug(`count by user: ${JSON.stringify(count_by_user)}`);
const count = await prisma.file.count();
logger.debug(`count files: ${JSON.stringify(count)}`);
llogger.debug(`count files: ${JSON.stringify(count)}`);
const views = await prisma.file.aggregate({
_sum: {
views: true,
},
});
logger.debug(`sum views: ${JSON.stringify(views)}`);
llogger.debug(`sum views: ${JSON.stringify(views)}`);
const typesCount = await prisma.file.groupBy({
by: ['mimetype'],
@ -132,7 +133,7 @@ export async function getStats(prisma: PrismaClient, datasource: Datasource, log
mimetype: true,
},
});
logger.debug(`types count: ${JSON.stringify(typesCount)}`);
llogger.debug(`types count: ${JSON.stringify(typesCount)}`);
const types_count = [];
for (let i = 0, L = typesCount.length; i !== L; ++i)
types_count.push({
@ -140,7 +141,7 @@ export async function getStats(prisma: PrismaClient, datasource: Datasource, log
count: typesCount[i]._count.mimetype,
});
logger.debug(`types count: ${JSON.stringify(types_count)}`);
llogger.debug(`types count: ${JSON.stringify(types_count)}`);
return {
size: bytesToHuman(size),

View file

@ -3063,9 +3063,9 @@ __metadata:
linkType: hard
"caniuse-lite@npm:^1.0.30001406":
version: 1.0.30001642
resolution: "caniuse-lite@npm:1.0.30001642"
checksum: 23f823ec115306eaf9299521328bb6ad0c4ce65254c375b14fd497ceda759ee8ee5b8763b7b622cb36b6b5fb53c6cb8569785fba842fe289be7dc3fcf008eb4f
version: 1.0.30001696
resolution: "caniuse-lite@npm:1.0.30001696"
checksum: 079be180f364b63fb85415fa3948d1e9646aa655f8678a827e9b533712e14d727c2983397603ce7107b995226f6590d96bf26ef2032e756ef6ee09898feee5f9
languageName: node
linkType: hard