Compare commits
No commits in common. "24a455f13833e73f48adb9af1cc3d18108a2dd79" and "cadbd324743c70b6562ca2411f6eb0f754ac5b78" have entirely different histories.
24a455f138
...
cadbd32474
13 changed files with 22 additions and 257 deletions
10
.env.example
10
.env.example
|
@ -1,11 +1 @@
|
||||||
BASE_URL=
|
|
||||||
DATABASE_URL=
|
DATABASE_URL=
|
||||||
BODY_SIZE_LIMIT=
|
|
||||||
|
|
||||||
MINIO_URL=
|
|
||||||
MINIO_ACCESS_KEY=
|
|
||||||
MINIO_SECRET_KEY=
|
|
||||||
MINIO_BUCKET=
|
|
||||||
|
|
||||||
THUMBOR_ENDPOINT=
|
|
||||||
THUMBOR_SECRET=
|
|
|
@ -7,17 +7,3 @@ services:
|
||||||
MARIADB_ROOT_PASSWORD: development
|
MARIADB_ROOT_PASSWORD: development
|
||||||
ports:
|
ports:
|
||||||
- '3306:3306'
|
- '3306:3306'
|
||||||
thumbor:
|
|
||||||
build:
|
|
||||||
dockerfile: thumbor.Dockerfile
|
|
||||||
restart: always
|
|
||||||
ports:
|
|
||||||
- '8888:80'
|
|
||||||
extra_hosts:
|
|
||||||
- 'host.docker.internal:host-gateway'
|
|
||||||
environment:
|
|
||||||
- 'SECURITY_KEY=development'
|
|
||||||
- 'ALLOW_UNSAFE_URL=False'
|
|
||||||
- 'STORAGE=thumbor.storages.no_storage'
|
|
||||||
- 'ENGINE=thumbor_video_engine.engines.video'
|
|
||||||
# - 'FILTERS=[thumbor_video_engine.filters.format,thumbor_video_engine.filters.still]'
|
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE `Thumbnail` (
|
|
||||||
`id` VARCHAR(191) NOT NULL,
|
|
||||||
`uploadId` VARCHAR(191) NOT NULL,
|
|
||||||
`placeholder` VARCHAR(191) NOT NULL,
|
|
||||||
|
|
||||||
UNIQUE INDEX `Thumbnail_uploadId_key`(`uploadId`),
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE `Thumbnail` ADD CONSTRAINT `Thumbnail_uploadId_fkey` FOREIGN KEY (`uploadId`) REFERENCES `Upload`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
|
@ -1,10 +0,0 @@
|
||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- You are about to drop the column `placeholder` on the `Thumbnail` table. All the data in the column will be lost.
|
|
||||||
- Added the required column `fileName` to the `Thumbnail` table without a default value. This is not possible if the table is not empty.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE `Thumbnail` DROP COLUMN `placeholder`,
|
|
||||||
ADD COLUMN `fileName` LONGTEXT NOT NULL;
|
|
|
@ -1,11 +0,0 @@
|
||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- The primary key for the `Thumbnail` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
|
||||||
- You are about to alter the column `id` on the `Thumbnail` table. The data in that column could be lost. The data in that column will be cast from `VarChar(191)` to `Int`.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE `Thumbnail` DROP PRIMARY KEY,
|
|
||||||
MODIFY `id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
||||||
ADD PRIMARY KEY (`id`);
|
|
|
@ -58,7 +58,6 @@ model Upload {
|
||||||
size Int
|
size Int
|
||||||
public Boolean @default(true)
|
public Boolean @default(true)
|
||||||
uploaded DateTime @default(now())
|
uploaded DateTime @default(now())
|
||||||
thumbnail Thumbnail?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model APIKey {
|
model APIKey {
|
||||||
|
@ -70,13 +69,6 @@ model APIKey {
|
||||||
permissions Int
|
permissions Int
|
||||||
}
|
}
|
||||||
|
|
||||||
model Thumbnail {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
upload Upload @relation(fields: [uploadId], references: [id])
|
|
||||||
uploadId String @unique
|
|
||||||
fileName String @db.LongText
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Role {
|
enum Role {
|
||||||
ADMINISTRATOR
|
ADMINISTRATOR
|
||||||
USER
|
USER
|
||||||
|
|
|
@ -1,35 +0,0 @@
|
||||||
<script>
|
|
||||||
import { bytesToHumanReadable } from '$lib';
|
|
||||||
import { File } from 'lucide-svelte';
|
|
||||||
import { fade } from 'svelte/transition';
|
|
||||||
|
|
||||||
export let upload, i;
|
|
||||||
let image = true;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<a
|
|
||||||
in:fade|global={{ duration: 500, delay: 100 * i }}
|
|
||||||
href="/file/{upload.id}"
|
|
||||||
class="flex flex-col gap-2 p-2 mx-auto w-full rounded-lg shadow-lg bg-crust"
|
|
||||||
>
|
|
||||||
<div class="flex w-full rounded-lg aspect-video bg-mantle">
|
|
||||||
{#if image}
|
|
||||||
<img
|
|
||||||
class="object-cover mx-auto w-full rounded-lg"
|
|
||||||
src="/api/v1/thumbnail/{upload.id}"
|
|
||||||
alt="Thumbnail"
|
|
||||||
on:error={() => {
|
|
||||||
image = false;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<File size="32" class="m-auto text-surface2"></File>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="font-bold overflow-ellipsis whitespace-nowrap overflow-x-clip">
|
|
||||||
{upload.fileName}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-overlay1">{bytesToHumanReadable(upload.size)}</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
|
@ -124,8 +124,7 @@ export async function getUpload(id: string) {
|
||||||
internalName: true,
|
internalName: true,
|
||||||
public: true,
|
public: true,
|
||||||
uploaded: true,
|
uploaded: true,
|
||||||
uploader: true,
|
uploader: true
|
||||||
thumbnail: true
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,48 +0,0 @@
|
||||||
import type { Upload } from '@prisma/client';
|
|
||||||
import { env } from '$env/dynamic/private';
|
|
||||||
import { createHmac, randomBytes } from 'node:crypto';
|
|
||||||
import prisma from './database';
|
|
||||||
import minio, { BUCKET } from './minio';
|
|
||||||
|
|
||||||
const processing = new Set();
|
|
||||||
|
|
||||||
export function createSignature(opts: string) {
|
|
||||||
return createHmac('SHA1', env.THUMBOR_SECRET || 'development')
|
|
||||||
.update(opts)
|
|
||||||
.digest();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createThumbnail(upload: Upload) {
|
|
||||||
if (processing.has(upload.id)) return false;
|
|
||||||
processing.add(upload.id);
|
|
||||||
|
|
||||||
const ticket = randomBytes(16).toString('hex');
|
|
||||||
const ticketSig = createSignature(ticket).toString('hex');
|
|
||||||
|
|
||||||
const url = `${env.BASE_URL}/download/${upload.id}/${ticket}.${ticketSig}`;
|
|
||||||
const options = '200x0/filters:format(webp):quality(50)';
|
|
||||||
|
|
||||||
const SIGNATURE = createSignature(`${options}/${url}`)
|
|
||||||
.toString('base64')
|
|
||||||
.replaceAll('+', '-')
|
|
||||||
.replaceAll('/', '_');
|
|
||||||
|
|
||||||
const response = await fetch(`${env.THUMBOR_ENDPOINT}/${SIGNATURE}/${options}/${url}`);
|
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
|
||||||
|
|
||||||
const filePath = `thumbnails/${Date.now()}-${upload.id}.webp`;
|
|
||||||
|
|
||||||
const record = await prisma.thumbnail.create({
|
|
||||||
data: {
|
|
||||||
uploadId: upload.id,
|
|
||||||
fileName: filePath
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await minio.putObject(BUCKET, filePath, Buffer.from(arrayBuffer), arrayBuffer.byteLength, {
|
|
||||||
'Content-Type': 'image/webp'
|
|
||||||
});
|
|
||||||
|
|
||||||
processing.delete(upload.id);
|
|
||||||
return record;
|
|
||||||
}
|
|
|
@ -1,11 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { File, ChevronLeft, ChevronRight } from 'lucide-svelte';
|
import { File, ChevronLeft, ChevronRight } from 'lucide-svelte';
|
||||||
|
import { bytesToHumanReadable } from '$lib';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
import { writable, get } from 'svelte/store';
|
import { writable, get } from 'svelte/store';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import Upload from '$lib/components/Upload.svelte';
|
|
||||||
|
|
||||||
export let data;
|
export let data;
|
||||||
|
|
||||||
|
@ -21,7 +21,21 @@
|
||||||
{#if uploads.length}
|
{#if uploads.length}
|
||||||
<div class="grid grid-cols-5 grid-rows-3 gap-2">
|
<div class="grid grid-cols-5 grid-rows-3 gap-2">
|
||||||
{#each uploads as upload, i}
|
{#each uploads as upload, i}
|
||||||
<Upload {upload} {i}></Upload>
|
<a
|
||||||
|
in:fade|global={{ duration: 500, delay: 100 * i }}
|
||||||
|
href="/file/{upload.id}"
|
||||||
|
class="flex flex-col gap-2 p-2 mx-auto w-full rounded-lg shadow-lg bg-crust"
|
||||||
|
>
|
||||||
|
<div class="flex w-full rounded-lg aspect-video bg-mantle">
|
||||||
|
<File size="32" class="m-auto text-surface2"></File>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-bold overflow-ellipsis whitespace-nowrap overflow-x-clip">
|
||||||
|
{upload.fileName}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-overlay1">{bytesToHumanReadable(upload.size)}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
|
@ -1,44 +0,0 @@
|
||||||
import { getUpload } from '$lib/server/database.js';
|
|
||||||
import minio, { BUCKET } from '$lib/server/minio';
|
|
||||||
import { createThumbnail } from '$lib/server/thumbnail';
|
|
||||||
import { error } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
export const GET = async ({ request, locals, params }) => {
|
|
||||||
let upload = await getUpload(params.id);
|
|
||||||
if (!upload) throw error(404, { status: 404, message: 'File Not Found' });
|
|
||||||
if (!upload.thumbnail) {
|
|
||||||
const record = await createThumbnail(upload as any);
|
|
||||||
if (!record) throw error(425, { status: 425, message: 'Too Early' });
|
|
||||||
upload.thumbnail = record;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!upload.public && locals?.user?.id !== upload.uploader.id)
|
|
||||||
throw error(403, { status: 403, message: 'Forbidden' });
|
|
||||||
|
|
||||||
const object = await minio.getObject(BUCKET, upload.thumbnail.fileName);
|
|
||||||
const metadata = await minio.statObject(BUCKET, upload.thumbnail.fileName);
|
|
||||||
|
|
||||||
const ac = new AbortController();
|
|
||||||
ac.signal.onabort = () => object.destroy;
|
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
|
||||||
start(controller) {
|
|
||||||
object.on('data', (chunk) => {
|
|
||||||
controller.enqueue(chunk);
|
|
||||||
});
|
|
||||||
object.on('end', () => {
|
|
||||||
controller.close();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
cancel() {
|
|
||||||
ac.abort();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(stream, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': metadata.metaData['content-type'],
|
|
||||||
'Content-Length': metadata.size.toString()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
|
@ -1,53 +0,0 @@
|
||||||
import { getUpload } from '$lib/server/database.js';
|
|
||||||
import minio, { BUCKET } from '$lib/server/minio';
|
|
||||||
import { createSignature } from '$lib/server/thumbnail.js';
|
|
||||||
import { error } from '@sveltejs/kit';
|
|
||||||
import { timingSafeEqual } from 'crypto';
|
|
||||||
|
|
||||||
function verifyTicket(data: string, sig: string) {
|
|
||||||
return timingSafeEqual(createSignature(data), Buffer.from(sig, 'hex'));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GET = async ({ params, locals }) => {
|
|
||||||
let id: any = params.id.split('.');
|
|
||||||
if (id.length > 1) id.pop();
|
|
||||||
id = id.join('');
|
|
||||||
|
|
||||||
const data = params.ticket.split('.')?.[0];
|
|
||||||
const sig = params.ticket.split('.')?.[1];
|
|
||||||
|
|
||||||
if (!data || !sig) throw error(403, { status: 403, message: 'Forbidden' });
|
|
||||||
|
|
||||||
const file = await getUpload(id);
|
|
||||||
if (!file) throw error(404, { status: 404, message: 'File Not Found' });
|
|
||||||
|
|
||||||
if (!verifyTicket(data, sig)) throw error(403, { status: 403, message: 'Forbidden' });
|
|
||||||
|
|
||||||
const object = await minio.getObject(BUCKET, `${file.uploader.id}/${file.internalName}`);
|
|
||||||
const metadata = await minio.statObject(BUCKET, `${file.uploader.id}/${file.internalName}`);
|
|
||||||
|
|
||||||
const ac = new AbortController();
|
|
||||||
ac.signal.onabort = () => object.destroy;
|
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
|
||||||
start(controller) {
|
|
||||||
object.on('data', (chunk) => {
|
|
||||||
controller.enqueue(chunk);
|
|
||||||
});
|
|
||||||
object.on('end', () => {
|
|
||||||
controller.close();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
cancel() {
|
|
||||||
ac.abort();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(stream, {
|
|
||||||
headers: {
|
|
||||||
'Content-Disposition': `attachment; filename="${file.fileName}"`,
|
|
||||||
'Content-Type': metadata.metaData['content-type'],
|
|
||||||
'Content-Length': metadata.size.toString()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
|
@ -1,3 +0,0 @@
|
||||||
FROM ghcr.io/minimalcompact/thumbor:latest
|
|
||||||
|
|
||||||
RUN pip install thumbor-video-engine
|
|
Loading…
Reference in a new issue