Compare commits

...

36 Commits

Author SHA1 Message Date
diced
48cfa41405 feat(v3.7.10): version 2024-09-12 11:56:05 -07:00
dicedtomato
9c26d64420 Merge commit from fork 2024-09-12 11:49:11 -07:00
diced
f3638f3d6d fix: delete file on maxViews in view route (#584) 2024-08-17 20:15:51 -07:00
polymo1
8e59158769 fix: hyprland is no longer wlroots-based (#581) 2024-08-05 17:45:47 -07:00
astrid
317c7365f8 fix: audio & video scrubbing (#576)
* fix video scrubbing

* fix scrubbing for audio as well
2024-07-19 12:40:52 -07:00
Matei Radu
974e9f7fa2 fix: fix flameshot script in readme (#575)
this commit fixes the json parsing in the example flameshot script. the previous example would just return a `jq` compile error
2024-07-15 14:27:54 -07:00
diced
4330bdcc4c fix: increment views on view/code routes (#572) 2024-07-12 12:22:01 -07:00
diced
7f9de82804 fix: apply loading and disabled to text upload button 2024-07-07 12:31:23 -07:00
diced
70050afb5f fix: ratelimit positioning 2024-07-07 11:02:53 -07:00
diced
1f00dd51f9 fix: thumbnails not showing up on folder view #563 2024-06-17 20:35:38 -07:00
diced
5e37d89b18 fix: latte & spelling 2024-06-07 18:11:01 -07:00
Seaswimmer
08d3bfb36d add various accenting colors 2024-06-07 16:07:45 -04:00
Seaswimmer
56f07cb5ec add Catppuccin themes 2024-06-07 15:47:07 -04:00
diced
658cc61df0 fix: order other user files by createdAt 2024-04-27 11:55:04 -07:00
diced
d3be545548 fix: prettier issue 2024-03-05 14:26:24 -08:00
reset
c8625c1e13 Merge pull request from GHSA-j2cw-9fvc-wr4r
https://github.com/diced/zipline/security/advisories/GHSA-j2cw-9fvc-wr4r
2024-03-05 14:22:35 -08:00
diced
511f17e1a5 feat(v3.7.9): version 2024-02-29 19:25:21 -08:00
diced
5b88b59724 fix: image resizing (#527) 2024-02-26 20:21:11 -08:00
diced
1816e13879 feat: ampm modifier for dates 2024-02-01 16:24:24 -08:00
diced
1a837c02d2 feat: auto-add to folder via api 2024-02-01 16:04:52 -08:00
diced
f3634eff48 fix: image resizing #527 2024-02-01 15:53:36 -08:00
diced
23ef407dd3 fix: bytesToHuman + bigint #532 2024-02-01 15:23:12 -08:00
diced
f40803f515 feat(v3.7.8): version 2024-01-04 23:53:24 -08:00
diced
6b97d30a69 fix: update copyright year 2024-01-04 23:27:08 -08:00
diced
bd8d4e33fd fix: max-width/height on image/video (#523) 2024-01-04 23:23:22 -08:00
Vetlix
70d48dd8c3 fix: prisma invite deletion errors (#522) (#520)
* fix: handle invite deletion error

* fix: handle url deletion error

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-12-24 21:12:54 -08:00
diced
2e0a5f1d9c feat: locale and tz options for localed date strings 2023-12-24 21:06:04 -08:00
Wingy
0ab814fc11 fix: better errors for expirations (#519)
* improve error handling for file expiry

* add missing semicolons

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-12-23 23:47:19 -08:00
Jayvin Hernandez
265760fb9c fix: merge create endpoint into register route (#517)
* fix: Merge create endpoint into register and prevent non admins from creating users.

* Why

* fix: Use `count` instead of `findMany` in consideration of RAM use.

* fix: Prevent repeats registers
2023-12-23 23:45:07 -08:00
diced
76ff3817af fix: apply mimetypes to s3 objects 2023-12-19 22:42:40 -08:00
Seaswimmer
0dfe3fdcd1 fix: ahk exts in mimes.json (#511)
* added autohotkey file extension (.ahk) to mimes.json

* added ahk1 and ahk2 file extensions

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-12-19 22:19:42 -08:00
William Harrison
5a522e0375 fix: typo (#513) 2023-12-19 22:17:45 -08:00
L7NEG
b15390f26c fix: remove pointless width/height tags (#509)
* Fix Discord Embed Res Bug

* Fixed Video Embed Res For Discord Mobile

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-12-11 22:01:28 -08:00
diced
6fef197620 fix: thumbnail not showing on folders (#510) 2023-12-11 21:34:16 -08:00
diced
1d0bb2fa4f fix: folder bigint (#505) 2023-12-05 15:51:30 -08:00
diced
abb5bb5f25 fix: align image (if present) to center #503 2023-12-05 15:48:07 -08:00
43 changed files with 669 additions and 283 deletions

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2023 dicedtomato Copyright (c) 2024 dicedtomato
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -121,7 +121,7 @@ This section requires [Flameshot](https://www.flameshot.org/), [jq](https://sted
If using wayland you will need to have [wl-clipboard](https://github.com/bugaevc/wl-clipboard) installed, for the `wl-copy` command. If using wayland you will need to have [wl-clipboard](https://github.com/bugaevc/wl-clipboard) installed, for the `wl-copy` command.
If you are not using GNOME/KDE/Qtile/Sway, and are using something like a wlroots-based compositor (ex. [Hyprland](https://github.com/hyprwm/Hyprland/), [River](https://github.com/riverwm/river), etc), you will need to set the `XDG_CURRENT_DESKTOP` environment variable to `sway`, which will just override it for this script. Adding `export XDG_CURRENT_DESKTOP=sway` to the start of the script will work. If you are not using GNOME/KDE/Qtile/Sway, and are using something like a wlroots-based or wlroots-compatible compositor (ex. [Hyprland](https://github.com/hyprwm/Hyprland/), [River](https://github.com/riverwm/river), etc), you will need to set the `XDG_CURRENT_DESKTOP` environment variable to `sway`, which will just override it for this script. Adding `export XDG_CURRENT_DESKTOP=sway` to the start of the script will work.
After this, replace the `xsel -ib` with `wl-copy` in the script. After this, replace the `xsel -ib` with `wl-copy` in the script.
@@ -141,7 +141,7 @@ To upload files using flameshot we will use a script. Replace $TOKEN and $HOST w
DATE=$(date '+%h_%Y_%d_%I_%m_%S.png'); DATE=$(date '+%h_%Y_%d_%I_%m_%S.png');
flameshot gui -r > ~/Pictures/$DATE; flameshot gui -r > ~/Pictures/$DATE;
curl -H "Content-Type: multipart/form-data" -H "authorization: $TOKEN" -F file=@$1 $HOST/api/upload | jq -r 'files[0].url' | xsel -ib curl -H "Content-Type: multipart/form-data" -H "authorization: $TOKEN" -F file=@$1 $HOST/api/upload | jq -r '.files[0]' | xsel -ib
``` ```
# Contributing # Contributing
@@ -169,4 +169,4 @@ Create a pull request on GitHub. If your PR does not pass the action checks, the
# Documentation # Documentation
Documentation source code is located in [diced/zipline-docs](https://github.com/diced/zipline-docs), and can be accessed [here](https://zipl.vercel.app). Documentation source code is located in [diced/zipline-docs](https://github.com/diced/zipline-docs), and can be accessed [here](https://zipl.vercel.app).

View File

@@ -42,6 +42,9 @@
["afm", ["application/octet-stream"]], ["afm", ["application/octet-stream"]],
["afp", ["application/vnd.ibm.modcap"]], ["afp", ["application/vnd.ibm.modcap"]],
["ahead", ["application/vnd.ahead.space"]], ["ahead", ["application/vnd.ahead.space"]],
["ahk", ["text/autohotkey"]],
["ahk1", ["text/autohotkey"]],
["ahk2", ["text/autohotkey"]],
["ai", ["application/postscript"]], ["ai", ["application/postscript"]],
["aif", ["audio/aiff"]], ["aif", ["audio/aiff"]],
["aifc", ["audio/aiff"]], ["aifc", ["audio/aiff"]],

View File

@@ -1,6 +1,6 @@
{ {
"name": "zipline", "name": "zipline",
"version": "3.7.7", "version": "3.7.10",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"dev": "npm-run-all build:server dev:run", "dev": "npm-run-all build:server dev:run",

View File

@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "Export" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"complete" BOOLEAN NOT NULL DEFAULT false,
"path" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
CONSTRAINT "Export_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Export" ADD CONSTRAINT "Export_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -27,6 +27,20 @@ model User {
Invite Invite[] Invite Invite[]
Folder Folder[] Folder Folder[]
IncompleteFile IncompleteFile[] IncompleteFile IncompleteFile[]
Exports Export[]
}
model Export {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
complete Boolean @default(false)
path String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
} }
model Folder { model Folder {

View File

@@ -72,6 +72,9 @@ export default function File({
}, },
transition: 'filter 0.2s ease-in-out', transition: 'filter 0.2s ease-in-out',
cursor: 'pointer', cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}} }}
shadow='md' shadow='md'
onClick={() => setOpen(true)} onClick={() => setOpen(true)}

View File

@@ -356,7 +356,7 @@ export default function Layout({ children, props }) {
) )
} }
variant='subtle' variant='subtle'
color='gray' color={theme.colorScheme === 'dark' ? 'dark' : 'gray'}
compact compact
size='xl' size='xl'
p='sm' p='sm'

View File

@@ -4,6 +4,10 @@ import { useEffect } from 'react';
import ayu_dark from 'lib/themes/ayu_dark'; import ayu_dark from 'lib/themes/ayu_dark';
import ayu_light from 'lib/themes/ayu_light'; import ayu_light from 'lib/themes/ayu_light';
import ayu_mirage from 'lib/themes/ayu_mirage'; import ayu_mirage from 'lib/themes/ayu_mirage';
import catppuccin_mocha from 'lib/themes/catppuccin_mocha';
import catppuccin_macchiato from 'lib/themes/catppuccin_macchiato';
import catppuccin_frappe from 'lib/themes/catppuccin_frappe';
import catppuccin_latte from 'lib/themes/catppuccin_latte';
import dark from 'lib/themes/dark'; import dark from 'lib/themes/dark';
import dark_blue from 'lib/themes/dark_blue'; import dark_blue from 'lib/themes/dark_blue';
import dracula from 'lib/themes/dracula'; import dracula from 'lib/themes/dracula';
@@ -32,6 +36,10 @@ export const themes = {
ayu_dark, ayu_dark,
ayu_mirage, ayu_mirage,
ayu_light, ayu_light,
catppuccin_mocha,
catppuccin_macchiato,
catppuccin_frappe,
catppuccin_latte,
nord, nord,
dracula, dracula,
matcha_dark_azul, matcha_dark_azul,
@@ -46,6 +54,10 @@ export const friendlyThemeName = {
ayu_dark: 'Ayu Dark', ayu_dark: 'Ayu Dark',
ayu_mirage: 'Ayu Mirage', ayu_mirage: 'Ayu Mirage',
ayu_light: 'Ayu Light', ayu_light: 'Ayu Light',
catppuccin_mocha: 'Catppuccin Mocha',
catppuccin_macchiato: 'Catppuccin Macchiato',
catppuccin_frappe: 'Catppuccin Frappé',
catppuccin_latte: 'Catppuccin Latte',
nord: 'Nord', nord: 'Nord',
dracula: 'Dracula', dracula: 'Dracula',
matcha_dark_azul: 'Matcha Dark Azul', matcha_dark_azul: 'Matcha Dark Azul',

View File

@@ -60,7 +60,7 @@ function VideoThumbnailPlaceholder({ file, mediaPreview, ...props }) {
return ( return (
<Box sx={{ position: 'relative' }}> <Box sx={{ position: 'relative' }}>
<Image <Image
src={file.thumbnail} src={typeof file.thumbnail === 'string' ? file.thumbnail : `/r/${file.thumbnail.name}`}
sx={{ sx={{
width: '100%', width: '100%',
height: 'auto', height: 'auto',

View File

@@ -41,6 +41,7 @@ import {
IconUserExclamation, IconUserExclamation,
IconUserMinus, IconUserMinus,
IconUserX, IconUserX,
IconX,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import AnchorNext from 'components/AnchorNext'; import AnchorNext from 'components/AnchorNext';
import { FlameshotIcon, ShareXIcon } from 'components/icons'; import { FlameshotIcon, ShareXIcon } from 'components/icons';
@@ -264,7 +265,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
setExports( setExports(
res.exports res.exports
?.map((s) => ({ ?.map((s) => ({
date: new Date(Number(s.name.split('_')[3].slice(0, -4))), date: new Date(s.createdAt),
size: s.size, size: s.size,
full: s.name, full: s.name,
})) }))
@@ -272,6 +273,26 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
); );
}; };
const deleteExport = async (name) => {
const res = await useFetch('/api/user/export?name=' + name, 'DELETE');
if (res.error) {
showNotification({
title: 'Error deleting export',
message: res.error,
color: 'red',
icon: <IconX size='1rem' />,
});
} else {
showNotification({
message: 'Deleted export',
color: 'green',
icon: <IconFileZip size='1rem' />,
});
await getExports();
}
};
const handleDelete = async () => { const handleDelete = async () => {
const res = await useFetch('/api/user/files', 'DELETE', { const res = await useFetch('/api/user/files', 'DELETE', {
all: true, all: true,
@@ -580,6 +601,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
{ id: 'name', name: 'Name' }, { id: 'name', name: 'Name' },
{ id: 'date', name: 'Date' }, { id: 'date', name: 'Date' },
{ id: 'size', name: 'Size' }, { id: 'size', name: 'Size' },
{ id: 'actions', name: '' },
]} ]}
rows={ rows={
exports exports
@@ -591,6 +613,11 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
), ),
date: x.date.toLocaleString(), date: x.date.toLocaleString(),
size: bytesToHuman(x.size), size: bytesToHuman(x.size),
actions: (
<ActionIcon onClick={() => deleteExport(x.full)}>
<IconTrash size='1rem' />
</ActionIcon>
),
})) }))
: [] : []
} }

View File

@@ -364,7 +364,8 @@ export default function File({ chunks: chunks_config }) {
<Button <Button
leftIcon={<IconFileUpload size='1rem' />} leftIcon={<IconFileUpload size='1rem' />}
onClick={handleUpload} onClick={handleUpload}
disabled={files.length === 0 ? true : false} loading={loading}
disabled={files.length === 0 || loading}
> >
Upload Upload
</Button> </Button>

View File

@@ -22,6 +22,7 @@ export default function Text() {
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const [lang, setLang] = useState('txt'); const [lang, setLang] = useState('txt');
const [loading, setLoading] = useState(false);
const [options, setOpened, OptionsModal] = useUploadOptions(); const [options, setOpened, OptionsModal] = useUploadOptions();
@@ -29,6 +30,9 @@ export default function Text() {
const shouldRenderTex = lang === 'tex'; const shouldRenderTex = lang === 'tex';
const handleUpload = async () => { const handleUpload = async () => {
if (value.trim().length === 0) return;
setLoading(true);
const file = new File([value], 'text.' + lang); const file = new File([value], 'text.' + lang);
const expiresAt = options.expires === 'never' ? null : expireReadToDate(options.expires); const expiresAt = options.expires === 'never' ? null : expireReadToDate(options.expires);
@@ -53,6 +57,16 @@ export default function Text() {
message: '', message: '',
}); });
showFilesModal(clipboard, modals, json.files); showFilesModal(clipboard, modals, json.files);
setLoading(false);
setValue('');
} else {
updateNotification({
id: 'upload-text',
title: 'Upload Failed',
message: json.error,
color: 'red',
});
setLoading(false);
} }
}); });
@@ -136,7 +150,8 @@ export default function Text() {
<Button <Button
leftIcon={<IconFileUpload size='1rem' />} leftIcon={<IconFileUpload size='1rem' />}
onClick={handleUpload} onClick={handleUpload}
disabled={value.trim().length === 0 ? true : false} disabled={value.trim().length === 0 || loading}
loading={loading}
> >
Upload Upload
</Button> </Button>

View File

@@ -26,7 +26,7 @@ export function CreateUserModal({ open, setOpen, updateUsers }) {
}; };
setOpen(false); setOpen(false);
const res = await useFetch('/api/auth/create', 'POST', data); const res = await useFetch('/api/auth/register', 'POST', data);
if (res.error) { if (res.error) {
showNotification({ showNotification({
title: 'Failed to create user', title: 'Failed to create user',

View File

@@ -3,7 +3,7 @@ import { Readable } from 'stream';
export abstract class Datasource { export abstract class Datasource {
public name: string; public name: string;
public abstract save(file: string, data: Buffer): Promise<void>; public abstract save(file: string, data: Buffer, options?: { type: string }): Promise<void>;
public abstract delete(file: string): Promise<void>; public abstract delete(file: string): Promise<void>;
public abstract clear(): Promise<void>; public abstract clear(): Promise<void>;
public abstract size(file: string): Promise<number>; public abstract size(file: string): Promise<number>;

View File

@@ -20,8 +20,13 @@ export class S3 extends Datasource {
}); });
} }
public async save(file: string, data: Buffer): Promise<void> { public async save(file: string, data: Buffer, options?: { type: string }): Promise<void> {
await this.s3.putObject(this.config.bucket, file, data); await this.s3.putObject(
this.config.bucket,
file,
data,
options ? { 'Content-Type': options.type } : undefined,
);
} }
public async delete(file: string): Promise<void> { public async delete(file: string): Promise<void> {

View File

@@ -0,0 +1,39 @@
// https://github.com/SeaswimmerTheFsh
// https://catppuccin.com/palette
import createTheme from '.';
export default createTheme({
colorScheme: 'dark',
primaryColor: 'blue',
other: {
AppShell_backgroundColor: '#232634',
hover: '#414559',
},
colors: {
dark: [
'#c6d0f5',
'#949cbb',
'#838ba7',
'#737994',
'#626880',
'#51576d',
'#414559',
'#303446',
'#292c3c',
'#232634',
],
blue: [
'#FFFFFF',
'#b8caf4',
'#a2baf1',
'#7599ea',
'#5f89e7',
'#8c99ee',
'#8ca1ee',
'#8cb2ee',
'#8cbaee',
'#8caaee',
],
},
});

View File

@@ -0,0 +1,39 @@
// https://github.com/SeaswimmerTheFsh
// https://catppuccin.com/palette
import createTheme from '.';
export default createTheme({
colorScheme: 'dark',
primaryColor: 'blue',
other: {
AppShell_backgroundColor: '#dce0e8',
hover: '#ccd0da',
},
colors: {
dark: [
'#4c4f69',
'#8c8fa1',
'#8c8fa1',
'#9ca0b0',
'#acb0be',
'#bcc0cc',
'#ccd0da',
'#eff1f5',
'#e6e9ef',
'#dce0e8',
],
blue: [
'#FFFFFF',
'#3676f6',
'#0a57ee',
'#094ed6',
'#1d42f5',
'#1d54f5',
'#1d65f5',
'#1d77f5',
'#1d89f5',
'#1e66f5',
],
},
});

View File

@@ -0,0 +1,39 @@
// https://github.com/SeaswimmerTheFsh
// https://catppuccin.com/palette
import createTheme from '.';
export default createTheme({
colorScheme: 'dark',
primaryColor: 'blue',
other: {
AppShell_backgroundColor: '#181926',
hover: '#363a4f',
},
colors: {
dark: [
'#cad3f5',
'#8087a2',
'#8087a2',
'#6e738d',
'#5b6078',
'#494d64',
'#363a4f',
'#24273a',
'#1e2030',
'#181926',
],
blue: [
'#FFFFFF',
'#a1bdf6',
'#729cf1',
'#5b8cef',
'#899bf4',
'#89a4f4',
'#89acf4',
'#89b5f4',
'#89bef4',
'#8aadf4',
],
},
});

View File

@@ -0,0 +1,39 @@
// https://github.com/SeaswimmerTheFsh
// https://catppuccin.com/palette
import createTheme from '.';
export default createTheme({
colorScheme: 'dark',
primaryColor: 'blue',
other: {
AppShell_backgroundColor: '#11111b',
hover: '#313244',
},
colors: {
dark: [
'#cdd6f4',
'#9399b2',
'#7f849c',
'#6c7086',
'#585b70',
'#45475a',
'#313244',
'#1e1e2e',
'#181825',
'#11111b',
],
blue: [
'#FFFFFF',
'#b9d3fc',
'#a1c3fb',
'#70a4f8',
'#5894f7',
'#89a1fa',
'#89aafa',
'#89b4fa',
'#89bdfa',
'#89c6fa',
],
},
});

View File

@@ -24,16 +24,16 @@ export function humanToBytes(value: string): number {
return bytes; return bytes;
} }
export function bytesToHuman(value: number): string { export function bytesToHuman(value: number | bigint): string {
if (isNaN(value)) return '0.0 B'; if (typeof value !== 'bigint' && isNaN(value)) return '0.0 B';
if (value === Infinity) return '0.0 B'; if (value === Infinity) return '0.0 B';
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB']; const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB']; // if people upload stuff bigger than a petabyte then idk
let num = 0; let num = 0;
while (value > 1024) { while (value > 1024) {
value /= 1024; value = Number(value) / 1024;
++num; ++num;
} }
return `${value.toFixed(1)} ${units[num]}`; return `${Number(value).toFixed(1)} ${units[num] || ''}`;
} }

View File

@@ -51,22 +51,22 @@ export function humanTime(string: StringValue | string): Date {
} }
} }
export function parseExpiry(header: string): Date | null { export function parseExpiry(header: string): Date {
if (!header) return null; if (!header) throw new Error('no expiry provided');
header = header.toLowerCase(); header = header.toLowerCase();
if (header.startsWith('date=')) { if (header.startsWith('date=')) {
const date = new Date(header.substring(5)); const date = new Date(header.substring(5));
if (!date.getTime()) return null; if (!date.getTime()) throw new Error('invalid date');
if (date.getTime() < Date.now()) return null; if (date.getTime() < Date.now()) throw new Error('expiry must be in the future');
return date; return date;
} }
const human = humanTime(header); const human = humanTime(header);
if (!human) return null; if (!human) throw new Error('failed to parse human time');
if (human.getTime() < Date.now()) return null; if (human.getTime() < Date.now()) throw new Error('expiry must be in the future');
return human; return human;
} }

View File

@@ -87,7 +87,7 @@ export async function removeGPSData(image: File): Promise<void> {
logger.debug(`reading file to upload to datasource: ${file} -> ${image.name}`); logger.debug(`reading file to upload to datasource: ${file} -> ${image.name}`);
const buffer = await readFile(file); const buffer = await readFile(file);
await datasource.save(image.name, buffer); await datasource.save(image.name, buffer, { type: image.mimetype });
logger.debug(`removing temp file: ${file}`); logger.debug(`removing temp file: ${file}`);
await rm(file); await rm(file);

View File

@@ -1,5 +1,6 @@
import type { File, User, Url } from '@prisma/client'; import type { File, User, Url } from '@prisma/client';
import { bytesToHuman } from './bytes'; import { bytesToHuman } from './bytes';
import Logger from 'lib/logger';
export type ParseValue = { export type ParseValue = {
file?: File; file?: File;
@@ -10,6 +11,8 @@ export type ParseValue = {
raw_link?: string; raw_link?: string;
}; };
const logger = Logger.get('parser');
export function parseString(str: string, value: ParseValue) { export function parseString(str: string, value: ParseValue) {
if (!str) return null; if (!str) return null;
str = str str = str
@@ -17,7 +20,7 @@ export function parseString(str: string, value: ParseValue) {
.replace(/\{raw_link\}/gi, value.raw_link) .replace(/\{raw_link\}/gi, value.raw_link)
.replace(/\\n/g, '\n'); .replace(/\\n/g, '\n');
const re = /\{(?<type>file|url|user)\.(?<prop>\w+)(::(?<mod>\w+))?\}/gi; const re = /\{(?<type>file|url|user)\.(?<prop>\w+)(::(?<mod>\w+))?(::(?<mod_tzlocale>\S+))?\}/gi;
let matches: RegExpMatchArray; let matches: RegExpMatchArray;
while ((matches = re.exec(str))) { while ((matches = re.exec(str))) {
@@ -54,7 +57,12 @@ export function parseString(str: string, value: ParseValue) {
} }
if (matches.groups.mod) { if (matches.groups.mod) {
str = replaceCharsFromString(str, modifier(matches.groups.mod, v), matches.index, re.lastIndex); str = replaceCharsFromString(
str,
modifier(matches.groups.mod, v, matches.groups.mod_tzlocale ?? undefined),
matches.index,
re.lastIndex,
);
re.lastIndex = matches.index; re.lastIndex = matches.index;
continue; continue;
} }
@@ -66,17 +74,42 @@ export function parseString(str: string, value: ParseValue) {
return str; return str;
} }
function modifier(mod: string, value: unknown): string { function modifier(mod: string, value: unknown, tzlocale?: string): string {
mod = mod.toLowerCase(); mod = mod.toLowerCase();
if (value instanceof Date) { if (value instanceof Date) {
const args = [undefined, undefined];
if (tzlocale) {
const [locale, tz] = tzlocale.split(/\s?,\s?/).map((v) => v.trim());
if (locale) {
try {
Intl.DateTimeFormat.supportedLocalesOf(locale);
args[0] = locale;
} catch (e) {
args[0] = undefined;
logger.error(`invalid locale provided ${locale}`);
}
}
if (tz) {
const intlTz = Intl.supportedValuesOf('timeZone').find((v) => v.toLowerCase() === tz.toLowerCase());
if (intlTz) args[1] = { timeZone: intlTz };
else {
args[1] = undefined;
logger.error(`invalid timezone provided ${tz}`);
}
}
}
switch (mod) { switch (mod) {
case 'locale': case 'locale':
return value.toLocaleString(); return value.toLocaleString(...args);
case 'time': case 'time':
return value.toLocaleTimeString(); return value.toLocaleTimeString(...args);
case 'date': case 'date':
return value.toLocaleDateString(); return value.toLocaleDateString(...args);
case 'unix': case 'unix':
return Math.floor(value.getTime() / 1000).toString(); return Math.floor(value.getTime() / 1000).toString();
case 'iso': case 'iso':
@@ -95,6 +128,10 @@ function modifier(mod: string, value: unknown): string {
return value.getMinutes().toString(); return value.getMinutes().toString();
case 'second': case 'second':
return value.getSeconds().toString(); return value.getSeconds().toString();
case 'ampm':
return value.getHours() < 12 ? 'am' : 'pm';
case 'AMPM':
return value.getHours() < 12 ? 'AM' : 'PM';
default: default:
return '{unknown_date_modifier}'; return '{unknown_date_modifier}';
} }
@@ -117,7 +154,7 @@ function modifier(mod: string, value: unknown): string {
default: default:
return '{unknown_str_modifier}'; return '{unknown_str_modifier}';
} }
} else if (typeof value === 'number') { } else if (typeof value === 'number' || typeof value === 'bigint') {
switch (mod) { switch (mod) {
case 'comma': case 'comma':
return value.toLocaleString(); return value.toLocaleString();

View File

@@ -1,132 +0,0 @@
import { readFile } from 'fs/promises';
import config from 'lib/config';
import Logger from 'lib/logger';
import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline';
import { guess } from 'lib/mimes';
import prisma from 'lib/prisma';
import { createToken, hashPassword } from 'lib/util';
import { jsonUserReplacer } from 'lib/utils/client';
import { extname } from 'path';
const logger = Logger.get('user');
async function handler(req: NextApiReq, res: NextApiRes) {
// handle invites
if (req.body.code) {
if (!config.features.invites) return res.badRequest('invites are disabled');
const { code, username, password } = req.body as {
code?: string;
username: string;
password: string;
};
const invite = await prisma.invite.findUnique({
where: { code: code ?? '' },
});
if (!invite && code) return res.badRequest('invalid invite code');
const user = await prisma.user.findFirst({
where: { username },
});
if (user) return res.badRequest('username already exists');
const hashed = await hashPassword(password);
let avatar;
if (config.features.default_avatar) {
logger.debug(`using default avatar ${config.features.default_avatar}`);
const buf = await readFile(config.features.default_avatar);
const mimetype = await guess(extname(config.features.default_avatar));
logger.debug(`guessed mimetype ${mimetype} for ${config.features.default_avatar}`);
avatar = `data:${mimetype};base64,${buf.toString('base64')}`;
}
const newUser = await prisma.user.create({
data: {
password: hashed,
username,
token: createToken(),
administrator: false,
avatar,
},
});
if (code) {
await prisma.invite.update({
where: {
code,
},
data: {
used: true,
},
});
}
logger.debug(`created user via invite ${code} ${JSON.stringify(newUser, jsonUserReplacer)}`);
logger.info(
`Created user ${newUser.username} (${newUser.id}) ${
code ? `from invite code ${code}` : 'via registration'
}`,
);
return res.json({ success: true });
}
const user = await req.user();
if (!user) return res.unauthorized('not logged in');
if (!user.administrator) return res.forbidden('you arent an administrator');
const { username, password, administrator } = req.body as {
username: string;
password: string;
administrator: boolean;
};
if (!username) return res.badRequest('no username');
if (!password) return res.badRequest('no password');
const existing = await prisma.user.findFirst({
where: {
username,
},
});
if (existing) return res.badRequest('user exists');
const hashed = await hashPassword(password);
let avatar;
if (config.features.default_avatar) {
logger.debug(`using default avatar ${config.features.default_avatar}`);
const buf = await readFile(config.features.default_avatar);
const mimetype = await guess(extname(config.features.default_avatar));
logger.debug(`guessed mimetype ${mimetype} for ${config.features.default_avatar}`);
avatar = `data:${mimetype};base64,${buf.toString('base64')}`;
}
const newUser = await prisma.user.create({
data: {
password: hashed,
username,
token: createToken(),
administrator,
avatar,
},
});
logger.debug(`created user ${JSON.stringify(newUser, jsonUserReplacer)}`);
delete newUser.password;
logger.info(`Created user ${newUser.username} (${newUser.id})`);
return res.json(newUser);
}
export default withZipline(handler, {
methods: ['POST'],
});

View File

@@ -1,3 +1,4 @@
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
import config from 'lib/config'; import config from 'lib/config';
import Logger from 'lib/logger'; import Logger from 'lib/logger';
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'lib/middleware/withZipline'; import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'lib/middleware/withZipline';
@@ -16,8 +17,12 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
count: number; count: number;
}; };
const expiry = parseExpiry(expiresAt); let expiry: Date;
if (!expiry) return res.badRequest('invalid date'); try {
expiry = parseExpiry(expiresAt);
} catch (error) {
return res.badRequest(error.message);
}
const counts = count ? count : 1; const counts = count ? count : 1;
if (counts > 1) { if (counts > 1) {
@@ -60,19 +65,22 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
const { code } = req.query as { code: string }; const { code } = req.query as { code: string };
if (!code) return res.badRequest('no code'); if (!code) return res.badRequest('no code');
const invite = await prisma.invite.delete({ try {
where: { const invite = await prisma.invite.delete({
code, where: {
}, code,
}); },
});
if (!invite) return res.notFound('invite not found'); logger.debug(`deleted invite ${JSON.stringify(invite)}`);
logger.debug(`deleted invite ${JSON.stringify(invite)}`); logger.info(`${user.username} (${user.id}) deleted invite ${invite.code}`);
logger.info(`${user.username} (${user.id}) deleted invite ${invite.code}`); return res.json(invite);
} catch (error) {
return res.json(invite); if (error instanceof PrismaClientKnownRequestError) return res.notFound('invite not found');
else throw error;
}
} else { } else {
const invites = await prisma.invite.findMany({ const invites = await prisma.invite.findMany({
orderBy: { orderBy: {

View File

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

View File

@@ -11,23 +11,49 @@ import { extname } from 'path';
const logger = Logger.get('user'); const logger = Logger.get('user');
async function handler(req: NextApiReq, res: NextApiRes) { async function handler(req: NextApiReq, res: NextApiRes) {
if (!config.features.user_registration) return res.badRequest('user registration is disabled'); const user = await req.user();
let badRequest,
usedInvite = false;
const { username, password, administrator } = req.body as { if (!config.features.user_registration && !config.features.invites && !user?.administrator)
return res.badRequest('This endpoint is unavailable due to current configurations');
else if (!!user && !user?.administrator) return res.badRequest('Already logged in');
const { username, password, administrator, code } = req.body as {
username: string; username: string;
password: string; password: string;
administrator: boolean; administrator: boolean;
code?: string;
}; };
if (!username) return res.badRequest('no username'); if (!username) badRequest = true;
if (!password) return res.badRequest('no password'); if (!password) badRequest = true;
const existing = await prisma.user.findFirst({ const existing = await prisma.user.findFirst({
where: { where: {
username, username,
}, },
select: {
username: true,
},
}); });
if (existing) return res.badRequest('user exists');
if (existing) badRequest = true;
if (badRequest) return res.badRequest('Bad Username/Password');
if (code) {
if (config.features.invites) {
const invite = await prisma.invite.findUnique({
where: {
code,
},
});
if (!invite || invite?.used) return res.badRequest('Bad invite');
usedInvite = true;
} else return res.badRequest('Bad Username/Password');
} else if (config.features.invites && !user?.administrator) return res.badRequest('Bad invite');
const hashed = await hashPassword(password); const hashed = await hashPassword(password);
@@ -47,12 +73,20 @@ async function handler(req: NextApiReq, res: NextApiRes) {
password: hashed, password: hashed,
username, username,
token: createToken(), token: createToken(),
administrator, administrator: user?.superAdmin ? administrator : false,
avatar, avatar,
}, },
}); });
logger.debug(`registered user ${JSON.stringify(newUser, jsonUserReplacer)}`); if (usedInvite)
await prisma.invite.update({
where: { code },
data: { used: true },
});
logger.debug(
`registered user${usedInvite ? ' via invite ' + code : ''} ${JSON.stringify(newUser, jsonUserReplacer)}`,
);
delete newUser.password; delete newUser.password;

View File

@@ -54,7 +54,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
logger.debug(`shortened ${JSON.stringify(url)}`); logger.debug(`shortened ${JSON.stringify(url)}`);
logger.info(`User ${user.username} (${user.id}) shortenned a url ${url.destination} (${url.id})`); logger.info(`User ${user.username} (${user.id}) shortened a url ${url.destination} (${url.id})`);
let domain; let domain;
if (req.headers['override-domain']) { if (req.headers['override-domain']) {

View File

@@ -30,6 +30,45 @@ async function handler(req: NextApiReq, res: NextApiRes) {
if (!user) return res.forbidden('authorization incorrect'); if (!user) return res.forbidden('authorization incorrect');
if (user.ratelimit && !req.headers['content-range']) {
const remaining = user.ratelimit.getTime() - Date.now();
logger.debug(`${user.id} encountered ratelimit, ${remaining}ms remaining`);
if (remaining <= 0) {
await prisma.user.update({
where: {
id: user.id,
},
data: {
ratelimit: null,
},
});
} else {
return res.ratelimited(remaining);
}
} else if (!user.ratelimit && !req.headers['content-range']) {
if (user.administrator && zconfig.ratelimit.admin > 0) {
await prisma.user.update({
where: {
id: user.id,
},
data: {
ratelimit: new Date(Date.now() + zconfig.ratelimit.admin * 1000),
},
});
} else if (!user.administrator && zconfig.ratelimit.user > 0) {
if (user.administrator && zconfig.ratelimit.user > 0) {
await prisma.user.update({
where: {
id: user.id,
},
data: {
ratelimit: new Date(Date.now() + zconfig.ratelimit.user * 1000),
},
});
}
}
}
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
uploader.array('file')(req as never, res as never, (result: unknown) => { uploader.array('file')(req as never, res as never, (result: unknown) => {
if (result instanceof Error) reject(result.message); if (result instanceof Error) reject(result.message);
@@ -42,6 +81,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
expiresAt?: Date; expiresAt?: Date;
removed_gps?: boolean; removed_gps?: boolean;
assumed_mimetype?: string | boolean; assumed_mimetype?: string | boolean;
folder?: number;
} = { } = {
files: [], files: [],
}; };
@@ -49,16 +89,20 @@ async function handler(req: NextApiReq, res: NextApiRes) {
let expiry: Date; let expiry: Date;
if (expiresAt) { if (expiresAt) {
expiry = parseExpiry(expiresAt); try {
if (!expiry) return res.badRequest('invalid date'); expiry = parseExpiry(expiresAt);
else {
response.expiresAt = expiry; response.expiresAt = expiry;
} catch (error) {
return res.badRequest(error.message);
} }
} }
if (zconfig.uploader.default_expiration) { if (zconfig.uploader.default_expiration) {
expiry = parseExpiry(zconfig.uploader.default_expiration); try {
if (!expiry) return res.badRequest('invalid date (UPLOADER_DEFAULT_EXPIRATION)'); expiry = parseExpiry(zconfig.uploader.default_expiration);
} catch (error) {
return res.badRequest(`${error.message} (UPLOADER_DEFAULT_EXPIRATION)`);
}
} }
const rawFormat = ((req.headers['format'] as string) || zconfig.uploader.default_format).toLowerCase(); const rawFormat = ((req.headers['format'] as string) || zconfig.uploader.default_format).toLowerCase();
@@ -78,6 +122,20 @@ async function handler(req: NextApiReq, res: NextApiRes) {
if (isNaN(fileMaxViews)) return res.badRequest('invalid max views (invalid number)'); if (isNaN(fileMaxViews)) return res.badRequest('invalid max views (invalid number)');
if (fileMaxViews < 0) return res.badRequest('invalid max views (max views < 0)'); if (fileMaxViews < 0) return res.badRequest('invalid max views (max views < 0)');
const folderToAdd = req.headers['x-zipline-folder'] ? Number(req.headers['x-zipline-folder']) : null;
if (folderToAdd) {
if (isNaN(folderToAdd)) return res.badRequest('invalid folder id (invalid number)');
const folder = await prisma.folder.findFirst({
where: {
id: folderToAdd,
userId: user.id,
},
});
if (!folder) return res.badRequest('invalid folder id (no folder found)');
response.folder = folder.id;
}
// handle partial uploads before ratelimits // handle partial uploads before ratelimits
if (req.headers['content-range'] && zconfig.chunks.enabled) { if (req.headers['content-range'] && zconfig.chunks.enabled) {
if (format === 'name') { if (format === 'name') {
@@ -128,6 +186,9 @@ async function handler(req: NextApiReq, res: NextApiRes) {
mimetype: req.headers.uploadtext ? 'text/plain' : mimetype, mimetype: req.headers.uploadtext ? 'text/plain' : mimetype,
userId: user.id, userId: user.id,
originalName: req.headers['original-name'] ? filename ?? null : null, originalName: req.headers['original-name'] ? filename ?? null : null,
...(folderToAdd && {
folderId: folderToAdd,
}),
}, },
}); });
@@ -175,23 +236,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
}); });
} }
if (user.ratelimit) {
const remaining = user.ratelimit.getTime() - Date.now();
logger.debug(`${user.id} encountered ratelimit, ${remaining}ms remaining`);
if (remaining <= 0) {
await prisma.user.update({
where: {
id: user.id,
},
data: {
ratelimit: null,
},
});
} else {
return res.ratelimited(remaining);
}
}
if (!req.files) return res.badRequest('no files'); if (!req.files) return res.badRequest('no files');
if (req.files && req.files.length === 0) return res.badRequest('no files'); if (req.files && req.files.length === 0) return res.badRequest('no files');
@@ -262,6 +306,9 @@ async function handler(req: NextApiReq, res: NextApiRes) {
maxViews: fileMaxViews, maxViews: fileMaxViews,
originalName: req.headers['original-name'] ? decodedName ?? null : null, originalName: req.headers['original-name'] ? decodedName ?? null : null,
size: file.size, size: file.size,
...(folderToAdd && {
folderId: folderToAdd,
}),
}, },
}); });
@@ -270,12 +317,12 @@ async function handler(req: NextApiReq, res: NextApiRes) {
if (compressionUsed) { if (compressionUsed) {
const buffer = await sharp(file.buffer).jpeg({ quality: imageCompressionPercent }).toBuffer(); const buffer = await sharp(file.buffer).jpeg({ quality: imageCompressionPercent }).toBuffer();
await datasource.save(fileUpload.name, buffer); await datasource.save(fileUpload.name, buffer, { type: 'image/jpeg' });
logger.info( logger.info(
`User ${user.username} (${user.id}) compressed image from ${file.buffer.length} -> ${buffer.length} bytes`, `User ${user.username} (${user.id}) compressed image from ${file.buffer.length} -> ${buffer.length} bytes`,
); );
} else { } else {
await datasource.save(fileUpload.name, file.buffer); await datasource.save(fileUpload.name, file.buffer, { type: file.mimetype });
} }
logger.info(`User ${user.username} (${user.id}) uploaded ${fileUpload.name} (${fileUpload.id})`); logger.info(`User ${user.username} (${user.id}) uploaded ${fileUpload.name} (${fileUpload.id})`);
@@ -315,28 +362,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
} }
} }
if (user.administrator && zconfig.ratelimit.admin > 0) {
await prisma.user.update({
where: {
id: user.id,
},
data: {
ratelimit: new Date(Date.now() + zconfig.ratelimit.admin * 1000),
},
});
} else if (!user.administrator && zconfig.ratelimit.user > 0) {
if (user.administrator && zconfig.ratelimit.user > 0) {
await prisma.user.update({
where: {
id: user.id,
},
data: {
ratelimit: new Date(Date.now() + zconfig.ratelimit.user * 1000),
},
});
}
}
if (req.headers['no-json']) { if (req.headers['no-json']) {
res.setHeader('Content-Type', 'text/plain'); res.setHeader('Content-Type', 'text/plain');
return res.end(response.files.join(',')); return res.end(response.files.join(','));

View File

@@ -21,6 +21,9 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
include: { include: {
thumbnail: true, thumbnail: true,
}, },
orderBy: {
createdAt: 'desc',
},
}, },
Folder: true, Folder: true,
}, },

View File

@@ -1,6 +1,6 @@
import { Zip, ZipPassThrough } from 'fflate'; import { Zip, ZipPassThrough } from 'fflate';
import { createReadStream, createWriteStream } from 'fs'; import { createReadStream, createWriteStream } from 'fs';
import { readdir, stat } from 'fs/promises'; import { rm, stat } from 'fs/promises';
import datasource from 'lib/datasource'; import datasource from 'lib/datasource';
import Logger from 'lib/logger'; import Logger from 'lib/logger';
import prisma from 'lib/prisma'; import prisma from 'lib/prisma';
@@ -23,6 +23,13 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
const export_name = `zipline_export_${user.id}_${Date.now()}.zip`; const export_name = `zipline_export_${user.id}_${Date.now()}.zip`;
const path = join(config.core.temp_directory, export_name); const path = join(config.core.temp_directory, export_name);
const exportDb = await prisma.export.create({
data: {
path: export_name,
userId: user.id,
},
});
logger.debug(`creating write stream at ${path}`); logger.debug(`creating write stream at ${path}`);
const write_stream = createWriteStream(path); const write_stream = createWriteStream(path);
@@ -79,11 +86,27 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
logger.info( logger.info(
`Export for ${user.username} (${user.id}) has completed and is available at ${export_name}`, `Export for ${user.username} (${user.id}) has completed and is available at ${export_name}`,
); );
await prisma.export.update({
where: {
id: exportDb.id,
},
data: {
complete: true,
},
});
} }
} else { } else {
write_stream.close(); write_stream.close();
logger.debug(`error while writing to zip: ${err}`); logger.error(
logger.error(`Export for ${user.username} (${user.id}) has failed\n${err}`); `Export for ${user.username} (${user.id}) has failed and has been removed from the database\n${err}`,
);
await prisma.export.delete({
where: {
id: exportDb.id,
},
});
} }
}; };
@@ -114,27 +137,62 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
res.json({ res.json({
url: '/api/user/export?name=' + export_name, url: '/api/user/export?name=' + export_name,
}); });
} else { } else if (req.method === 'DELETE') {
const export_name = req.query.name as string; const name = req.query.name as string;
if (export_name) { if (!name) return res.badRequest('no name provided');
const parts = export_name.split('_');
if (Number(parts[2]) !== user.id) return res.unauthorized('cannot access export owned by another user');
const stream = createReadStream(join(config.core.temp_directory, export_name)); const exportDb = await prisma.export.findFirst({
where: {
userId: user.id,
path: name,
},
});
if (!exportDb) return res.notFound('export not found');
await prisma.export.delete({
where: {
id: exportDb.id,
},
});
try {
await rm(join(config.core.temp_directory, exportDb.path));
} catch (e) {
logger
.error(`export file ${exportDb.path} has been removed from the database`)
.error(`but failed to remove the file from the filesystem: ${e}`);
}
res.json({
success: true,
});
} else {
const exportsDb = await prisma.export.findMany({
where: {
userId: user.id,
},
});
const name = req.query.name as string;
if (name) {
const exportDb = exportsDb.find((e) => e.path === name);
if (!exportDb) return res.notFound('export not found');
const stream = createReadStream(join(config.core.temp_directory, exportDb.path));
res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${export_name}"`); res.setHeader('Content-Disposition', `attachment; filename="${exportDb.path}"`);
stream.pipe(res); stream.pipe(res);
} else { } else {
const files = await readdir(config.core.temp_directory);
const exp = files.filter((f) => f.startsWith('zipline_export_'));
const exports = []; const exports = [];
for (let i = 0; i !== exp.length; ++i) {
const name = exp[i];
const stats = await stat(join(config.core.temp_directory, name));
if (Number(exp[i].split('_')[2]) !== user.id) continue; for (let i = 0; i !== exportsDb.length; ++i) {
exports.push({ name, size: stats.size }); const exportDb = exportsDb[i];
if (!exportDb.complete) continue;
const stats = await stat(join(config.core.temp_directory, exportDb.path));
exports.push({ name: exportDb.path, size: stats.size, createdAt: exportDb.createdAt });
} }
res.json({ res.json({
@@ -145,6 +203,6 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
} }
export default withZipline(handler, { export default withZipline(handler, {
methods: ['GET', 'POST'], methods: ['GET', 'POST', 'DELETE'],
user: true, user: true,
}); });

View File

@@ -16,7 +16,13 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
id: idParsed, id: idParsed,
}, },
select: { select: {
files: !!req.query.files, files: req.query.files
? {
include: {
thumbnail: true,
},
}
: false,
id: true, id: true,
name: true, name: true,
userId: true, userId: true,
@@ -70,7 +76,13 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
}, },
}, },
select: { select: {
files: !!req.query.files, files: req.query.files
? {
include: {
thumbnail: true,
},
}
: false,
id: true, id: true,
name: true, name: true,
userId: true, userId: true,
@@ -111,7 +123,13 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
public: !!publicFolder, public: !!publicFolder,
}, },
select: { select: {
files: !!req.query.files, files: req.query.files
? {
include: {
thumbnail: true,
},
}
: false,
id: true, id: true,
name: true, name: true,
userId: true, userId: true,
@@ -200,7 +218,13 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
}, },
}, },
select: { select: {
files: !!req.query.files, files: req.query.files
? {
include: {
thumbnail: true,
},
}
: false,
id: true, id: true,
name: true, name: true,
userId: true, userId: true,

View File

@@ -1,3 +1,4 @@
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
import config from 'lib/config'; import config from 'lib/config';
import Logger from 'lib/logger'; import Logger from 'lib/logger';
import prisma from 'lib/prisma'; import prisma from 'lib/prisma';
@@ -8,15 +9,22 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
if (req.method === 'DELETE') { if (req.method === 'DELETE') {
if (!req.body.id) return res.badRequest('no url id'); if (!req.body.id) return res.badRequest('no url id');
const url = await prisma.url.delete({ try {
where: { const url = await prisma.url.delete({
id: req.body.id, where: {
}, id: req.body.id,
}); },
});
Logger.get('url').info(`User ${user.username} (${user.id}) deleted a url ${url.destination} (${url.id})`); Logger.get('url').info(
`User ${user.username} (${user.id}) deleted a url ${url.destination} (${url.id})`,
);
return res.json(url); return res.json(url);
} catch (err) {
if (err instanceof PrismaClientKnownRequestError) return res.notFound('url not found');
else throw err;
}
} else { } else {
const urls = await prisma.url.findMany({ const urls = await prisma.url.findMany({
where: { where: {

View File

@@ -50,7 +50,7 @@ export default function Register({ code = undefined, title, user_registration })
}; };
const createUser = async () => { const createUser = async () => {
const res = await useFetch(`/api/auth/${user_registration ? 'register' : 'create'}`, 'POST', { const res = await useFetch('/api/auth/register', 'POST', {
code: user_registration ? null : code, code: user_registration ? null : code,
username, username,
password, password,

View File

@@ -115,6 +115,17 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
renderType = null; renderType = null;
} }
await prisma.file.update({
where: {
id: file.id,
},
data: {
views: {
increment: 1,
},
},
});
return { return {
props: { props: {
code: await streamToString(data), code: await streamToString(data),

View File

@@ -85,6 +85,12 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) =>
createdAt: true, createdAt: true,
password: true, password: true,
size: true, size: true,
thumbnail: {
select: {
name: true,
id: true,
},
},
}, },
}, },
user: { user: {
@@ -106,6 +112,9 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) =>
folder.files[j].name, folder.files[j].name,
); );
// @ts-ignore
folder.files[j].size = Number(folder.files[j].size);
// @ts-ignore // @ts-ignore
if (folder.files[j].password) folder.files[j].password = true; if (folder.files[j].password) folder.files[j].password = true;

View File

@@ -11,6 +11,7 @@ import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import zconfig from 'lib/config'; import zconfig from 'lib/config';
import { log } from 'server/util';
export default function EmbeddedFile({ export default function EmbeddedFile({
file, file,
@@ -63,11 +64,15 @@ export default function EmbeddedFile({
const img = new Image(); const img = new Image();
img.addEventListener('load', function () { img.addEventListener('load', function () {
if (this.naturalWidth > innerWidth) // my best attempt of recreating https://searchfox.org/mozilla-central/source/dom/html/ImageDocument.cpp#271-276
imageEl.width = Math.floor( // and it actually works
this.naturalWidth * Math.min(innerHeight / this.naturalHeight, innerWidth / this.naturalWidth),
); const ratio = Math.min(innerHeight / this.naturalHeight, innerWidth / this.naturalWidth);
else imageEl.width = 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;
}); });
img.src = url || dataURL('/r'); img.src = url || dataURL('/r');
@@ -80,11 +85,19 @@ export default function EmbeddedFile({
useEffect(() => { useEffect(() => {
if (pass) { if (pass) {
setOpened(true); setOpened(true);
} else {
updateImage();
} }
}, []); }, []);
useEffect(() => {
if (!file?.mimetype?.startsWith('image')) return;
updateImage();
window.addEventListener('resize', () => updateImage());
return () => {
window.removeEventListener('resize', () => updateImage());
};
}, []);
return ( return (
<> <>
<Head> <Head>
@@ -124,8 +137,6 @@ export default function EmbeddedFile({
<meta name='twitter:card' content='player' /> <meta name='twitter:card' content='player' />
<meta name='twitter:player' content={`${host}/r/${file.name}`} /> <meta name='twitter:player' content={`${host}/r/${file.name}`} />
<meta name='twitter:player:stream' content={`${host}/r/${file.name}`} /> <meta name='twitter:player:stream' content={`${host}/r/${file.name}`} />
<meta name='twitter:player:width' content='720' />
<meta name='twitter:player:height' content='480' />
<meta name='twitter:player:stream:content_type' content={file.mimetype} /> <meta name='twitter:player:stream:content_type' content={file.mimetype} />
<meta name='twitter:title' content={file.name} /> <meta name='twitter:title' content={file.name} />
@@ -142,8 +153,6 @@ export default function EmbeddedFile({
<meta property='og:video:url' content={`${host}/r/${file.name}`} /> <meta property='og:video:url' content={`${host}/r/${file.name}`} />
<meta property='og:video:secure_url' content={`${host}/r/${file.name}`} /> <meta property='og:video:secure_url' content={`${host}/r/${file.name}`} />
<meta property='og:video:type' content={file.mimetype} /> <meta property='og:video:type' content={file.mimetype} />
<meta property='og:video:width' content='720' />
<meta property='og:video:height' content='480' />
</> </>
)} )}
{file.mimetype.startsWith('audio') && ( {file.mimetype.startsWith('audio') && (
@@ -200,7 +209,17 @@ export default function EmbeddedFile({
)} )}
{file.mimetype.startsWith('video') && ( {file.mimetype.startsWith('video') && (
<video src={dataURL('/r')} controls autoPlay muted id='video_content' /> <video
style={{
maxHeight: '100vh',
maxWidth: '100vw',
}}
src={dataURL('/r')}
controls
autoPlay
muted
id='video_content'
/>
)} )}
{file.mimetype.startsWith('audio') && ( {file.mimetype.startsWith('audio') && (
@@ -233,6 +252,17 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
let host = context.req.headers.host; let host = context.req.headers.host;
if (!file) return { notFound: true }; if (!file) return { notFound: true };
const logger = log('view');
if (file.maxViews && file.views >= file.maxViews) {
await datasource.delete(file.name);
await prisma.file.delete({ where: { id: file.id } });
logger.child('file').info(`File ${file.name} has been deleted due to max views (${file.maxViews})`);
return { notFound: true };
}
// @ts-ignore // @ts-ignore
file.size = Number(file.size); file.size = Number(file.size);
@@ -258,6 +288,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
delete user.password; delete user.password;
delete user.totpSecret; delete user.totpSecret;
delete user.token; delete user.token;
delete user.ratelimit;
// @ts-ignore workaround because next wont allow date // @ts-ignore workaround because next wont allow date
file.createdAt = file.createdAt.toString(); file.createdAt = file.createdAt.toString();
@@ -306,6 +337,17 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
// @ts-ignore // @ts-ignore
if (file.password) file.password = true; if (file.password) file.password = true;
await prisma.file.update({
where: {
id: file.id,
},
data: {
views: {
increment: 1,
},
},
});
return { return {
props: { props: {
file, file,

View File

@@ -29,12 +29,12 @@ async function main() {
const mime = await guess(files[i].split('.').pop()); const mime = await guess(files[i].split('.').pop());
const { size } = statSync(join(directory, files[i])); const { size } = statSync(join(directory, files[i]));
data.push({ data[i] = {
name: files[i], name: files[i],
mimetype: mime, mimetype: mime,
userId, userId,
size, size,
}); };
console.log(`Imported ${files[i]} (${bytesToHuman(size)}) (${mime} mimetype) to user ${userId}`); console.log(`Imported ${files[i]} (${bytesToHuman(size)}) (${mime} mimetype) to user ${userId}`);
} }
@@ -54,7 +54,9 @@ async function main() {
console.log(`Copying files to ${config.datasource.type} storage..`); console.log(`Copying files to ${config.datasource.type} storage..`);
for (let i = 0; i !== files.length; ++i) { for (let i = 0; i !== files.length; ++i) {
const file = files[i]; const file = files[i];
await datasource.save(file, await readFile(join(directory, file))); await datasource.save(file, await readFile(join(directory, file)), {
type: data[i]?.mimetype ?? 'application/octet-stream',
});
} }
console.log(`Finished copying files to ${config.datasource.type} storage.`); console.log(`Finished copying files to ${config.datasource.type} storage.`);

View File

@@ -21,6 +21,11 @@ function dbFileDecorator(fastify: FastifyInstance, _, done) {
this.header('Content-Length', size); this.header('Content-Length', size);
this.header('Content-Type', download ? 'application/octet-stream' : file.mimetype); this.header('Content-Type', download ? 'application/octet-stream' : file.mimetype);
this.header('Content-Disposition', `inline; filename="${encodeURI(file.originalName || file.name)}"`); this.header('Content-Disposition', `inline; filename="${encodeURI(file.originalName || file.name)}"`);
if (file.mimetype.startsWith('video/') || file.mimetype.startsWith('audio/')) {
this.header('Accept-Ranges', 'bytes');
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range
this.header('Content-Range', `bytes 0-${size - 1}/${size}`);
}
return this.send(data); return this.send(data);
} }

View File

@@ -115,7 +115,7 @@ async function start() {
}, },
}); });
await datasource.save(thumb.name, thumbnail); await datasource.save(thumb.name, thumbnail, { type: 'image/jpeg' });
logger.info(`thumbnail saved - ${thumb.name}`); logger.info(`thumbnail saved - ${thumb.name}`);
logger.debug(`thumbnail ${JSON.stringify(thumb)}`); logger.debug(`thumbnail ${JSON.stringify(thumb)}`);

View File

@@ -121,7 +121,9 @@ async function start() {
await fd.close(); await fd.close();
} else { } else {
logger.debug('writing file to datasource'); logger.debug('writing file to datasource');
await datasource.save(file.filename, Buffer.from(fd as Uint8Array)); await datasource.save(file.filename, Buffer.from(fd as Uint8Array), {
type: file.mimetype ?? 'application/octet-stream',
});
} }
const final = await prisma.incompleteFile.update({ const final = await prisma.incompleteFile.update({

View File

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