diff --git a/prisma/migrations/20220728183855_expiring_images/migration.sql b/prisma/migrations/20220728183855_expiring_images/migration.sql
new file mode 100644
index 0000000..b30e87c
--- /dev/null
+++ b/prisma/migrations/20220728183855_expiring_images/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Image" ADD COLUMN "expires_at" TIMESTAMP(3);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index adacefd..abd4950 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -36,6 +36,7 @@ model Image {
file String
mimetype String @default("image/png")
created_at DateTime @default(now())
+ expires_at DateTime?
views Int @default(0)
favorite Boolean @default(false)
embed Boolean @default(false)
diff --git a/src/components/File.tsx b/src/components/File.tsx
index 8712fdd..f49dcda 100644
--- a/src/components/File.tsx
+++ b/src/components/File.tsx
@@ -4,8 +4,9 @@ import { showNotification } from '@mantine/notifications';
import useFetch from 'hooks/useFetch';
import { useState } from 'react';
import Type from './Type';
-import { CalendarIcon, CopyIcon, CrossIcon, DeleteIcon, FileIcon, HashIcon, ImageIcon, StarIcon } from './icons';
+import { CalendarIcon, ClockIcon, CopyIcon, CrossIcon, DeleteIcon, FileIcon, HashIcon, ImageIcon, StarIcon } from './icons';
import MutedText from './MutedText';
+import { relativeTime } from 'lib/clientUtils';
export function FileMeta({ Icon, title, subtitle }) {
return (
@@ -22,7 +23,6 @@ export function FileMeta({ Icon, title, subtitle }) {
export default function File({ image, updateImages }) {
const [open, setOpen] = useState(false);
const clipboard = useClipboard();
- const theme = useMantineTheme();
const handleDelete = async () => {
const res = await useFetch('/api/user/files', 'DELETE', { id: image.id });
@@ -88,6 +88,7 @@ export default function File({ image, updateImages }) {
+ {image.expires_at && }
diff --git a/src/components/icons/ClockIcon.tsx b/src/components/icons/ClockIcon.tsx
new file mode 100644
index 0000000..e55f7a2
--- /dev/null
+++ b/src/components/icons/ClockIcon.tsx
@@ -0,0 +1,5 @@
+import { Clock } from 'react-feather';
+
+export default function ClockIcon({ ...props }) {
+ return ;
+}
\ No newline at end of file
diff --git a/src/components/icons/index.tsx b/src/components/icons/index.tsx
index d9530ad..45d17a4 100644
--- a/src/components/icons/index.tsx
+++ b/src/components/icons/index.tsx
@@ -22,6 +22,7 @@ import PlayIcon from './PlayIcon';
import CalendarIcon from './CalendarIcon';
import HashIcon from './HashIcon';
import TagIcon from './TagIcon';
+import ClockIcon from './ClockIcon';
export {
ActivityIcon,
@@ -48,4 +49,5 @@ export {
CalendarIcon,
HashIcon,
TagIcon,
+ ClockIcon,
};
\ No newline at end of file
diff --git a/src/components/pages/Upload.tsx b/src/components/pages/Upload.tsx
index 0fef926..0422a1a 100644
--- a/src/components/pages/Upload.tsx
+++ b/src/components/pages/Upload.tsx
@@ -1,13 +1,44 @@
-import { Button, Collapse, Group, Progress, Title } from '@mantine/core';
+import { Button, Collapse, Group, Progress, Select, Title } from '@mantine/core';
import { randomId, useClipboard } from '@mantine/hooks';
import { showNotification, updateNotification } from '@mantine/notifications';
import Dropzone from 'components/dropzone/Dropzone';
import FileDropzone from 'components/dropzone/DropzoneFile';
-import { CrossIcon, UploadIcon } from 'components/icons';
+import { ClockIcon, CrossIcon, UploadIcon } from 'components/icons';
import Link from 'components/Link';
import { useStoreSelector } from 'lib/redux/store';
import { useEffect, useState } from 'react';
+const expires = [
+ '5min',
+ '10min',
+ '15min',
+ '30min',
+ '1h',
+ '2h',
+ '3h',
+ '4h',
+ '5h',
+ '6h',
+ '8h',
+ '12h',
+ '1d',
+ '3d',
+ '5d',
+ '7d',
+ '1w',
+ '1.5w',
+ '2w',
+ '3w',
+ '1m',
+ '1.5m',
+ '2m',
+ '3m',
+ '6m',
+ '8m',
+ '1y',
+ 'never',
+];
+
export default function Upload() {
const clipboard = useClipboard();
const user = useStoreSelector(state => state.user);
@@ -15,6 +46,7 @@ export default function Upload() {
const [files, setFiles] = useState([]);
const [progress, setProgress] = useState(0);
const [loading, setLoading] = useState(false);
+ const [expires, setExpires] = useState('never');
useEffect(() => {
window.addEventListener('paste', (e: ClipboardEvent) => {
@@ -29,6 +61,36 @@ export default function Upload() {
});
const handleUpload = async () => {
+ const expires_at = expires === 'never' ? null : new Date({
+ '5min': Date.now() + 5 * 60 * 1000,
+ '10min': Date.now() + 10 * 60 * 1000,
+ '15min': Date.now() + 15 * 60 * 1000,
+ '30min': Date.now() + 30 * 60 * 1000,
+ '1h': Date.now() + 60 * 60 * 1000,
+ '2h': Date.now() + 2 * 60 * 60 * 1000,
+ '3h': Date.now() + 3 * 60 * 60 * 1000,
+ '4h': Date.now() + 4 * 60 * 60 * 1000,
+ '5h': Date.now() + 5 * 60 * 60 * 1000,
+ '6h': Date.now() + 6 * 60 * 60 * 1000,
+ '8h': Date.now() + 8 * 60 * 60 * 1000,
+ '12h': Date.now() + 12 * 60 * 60 * 1000,
+ '1d': Date.now() + 24 * 60 * 60 * 1000,
+ '3d': Date.now() + 3 * 24 * 60 * 60 * 1000,
+ '5d': Date.now() + 5 * 24 * 60 * 60 * 1000,
+ '7d': Date.now() + 7 * 24 * 60 * 60 * 1000,
+ '1w': Date.now() + 7 * 24 * 60 * 60 * 1000,
+ '1.5w': Date.now() + 1.5 * 7 * 24 * 60 * 60 * 1000,
+ '2w': Date.now() + 2 * 7 * 24 * 60 * 60 * 1000,
+ '3w': Date.now() + 3 * 7 * 24 * 60 * 60 * 1000,
+ '1m': Date.now() + 30 * 24 * 60 * 60 * 1000,
+ '1.5m': Date.now() + 1.5 * 30 * 24 * 60 * 60 * 1000,
+ '2m': Date.now() + 2 * 30 * 24 * 60 * 60 * 1000,
+ '3m': Date.now() + 3 * 30 * 24 * 60 * 60 * 1000,
+ '6m': Date.now() + 6 * 30 * 24 * 60 * 60 * 1000,
+ '8m': Date.now() + 8 * 30 * 24 * 60 * 60 * 1000,
+ '1y': Date.now() + 365 * 24 * 60 * 60 * 1000,
+ }[expires]);
+
setProgress(0);
setLoading(true);
const body = new FormData();
@@ -78,6 +140,7 @@ export default function Upload() {
req.open('POST', '/api/upload');
req.setRequestHeader('Authorization', user.token);
+ req.setRequestHeader('Expires-At', expires_at.toISOString());
req.send(body);
};
@@ -95,8 +158,43 @@ export default function Upload() {
{progress !== 0 && }
-
- } mt={12} onClick={handleUpload} disabled={files.length === 0 ? true : false}>Upload
+
+
>
);
diff --git a/src/lib/clientUtils.ts b/src/lib/clientUtils.ts
index 581c555..e1b09dc 100644
--- a/src/lib/clientUtils.ts
+++ b/src/lib/clientUtils.ts
@@ -27,4 +27,26 @@ export function bytesToRead(bytes: number) {
}
return `${bytes.toFixed(1)} ${units[num]}`;
-}
\ No newline at end of file
+}
+
+export const units = {
+ year: 365 * 24 * 60 * 60 * 1000,
+ month: 30 * 24 * 60 * 60 * 1000,
+ day: 24 * 60 * 60 * 1000,
+ hour: 60 * 60 * 1000,
+ minute: 60 * 1000,
+ second: 1000,
+};
+
+export function relativeTime(to: Date, from: Date = new Date()) {
+ const time = new Date(to.getTime() - from.getTime());
+
+ const rtf = new Intl.RelativeTimeFormat('en', { style: 'long' });
+
+ for (const unit in units) {
+ if (time > units[unit]) {
+ return rtf.format(Math.floor(Math.round(time.getTime() / units[unit])), unit as Intl.RelativeTimeFormatUnit || 'second');
+ }
+ }
+}
+
diff --git a/src/pages/api/upload.ts b/src/pages/api/upload.ts
index c5e5dd8..8482c54 100644
--- a/src/pages/api/upload.ts
+++ b/src/pages/api/upload.ts
@@ -44,6 +44,13 @@ async function handler(req: NextApiReq, res: NextApiRes) {
if (req.files && req.files.length === 0) return res.error('no files');
+ const expires_at = req.headers['expires-at'] as string;
+ const expiry = expires_at ? new Date(expires_at) : null;
+ if (expiry) {
+ if (!expiry.getTime()) return res.bad('invalid date');
+ if (expiry.getTime() < Date.now()) return res.bad('date is in the past');
+ }
+
const rawFormat = ((req.headers.format || '') as string).toUpperCase() || 'RANDOM';
const format: ImageFormat = Object.keys(ImageFormat).includes(rawFormat) && ImageFormat[rawFormat];
const files = [];
@@ -84,6 +91,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
embed: !!req.headers.embed,
format,
password,
+ expires_at: expiry,
},
});
diff --git a/src/pages/api/user/files.ts b/src/pages/api/user/files.ts
index 810915d..19693b0 100644
--- a/src/pages/api/user/files.ts
+++ b/src/pages/api/user/files.ts
@@ -69,6 +69,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
},
select: {
created_at: true,
+ expires_at: true,
file: true,
mimetype: true,
id: true,
diff --git a/src/server/index.ts b/src/server/index.ts
index 4c3aa3f..711daef 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -143,6 +143,14 @@ async function rawFileDb(
prisma: PrismaClient,
image: Image,
) {
+ if (image.expires_at && image.expires_at < new Date()) {
+ Logger.get('server').info(`${image.file} expired`);
+ await datasource.delete(image.file);
+ await prisma.image.delete({ where: { id: image.id } });
+
+ return nextServer.render404(req, res as ServerResponse);
+ }
+
const data = await datasource.get(image.file);
if (!data) return nextServer.render404(req, res as ServerResponse);
@@ -169,6 +177,13 @@ async function fileDb(
handle: RequestHandler,
image: Image,
) {
+ if (image.expires_at && image.expires_at < new Date()) {
+ await datasource.delete(image.file);
+ await prisma.image.delete({ where: { id: image.id } });
+
+ return nextServer.render404(req, res as ServerResponse);
+ }
+
const ext = image.file.split('.').pop();
if (Object.keys(exts).includes(ext)) return handle(req, res as ServerResponse);