thumbnailing #7
13 changed files with 257 additions and 22 deletions
10
.env.example
10
.env.example
|
@ -1 +1,11 @@
|
||||||
|
BASE_URL=
|
||||||
DATABASE_URL=
|
DATABASE_URL=
|
||||||
|
BODY_SIZE_LIMIT=
|
||||||
|
|
||||||
|
MINIO_URL=
|
||||||
|
MINIO_ACCESS_KEY=
|
||||||
|
MINIO_SECRET_KEY=
|
||||||
|
MINIO_BUCKET=
|
||||||
|
|
||||||
|
THUMBOR_ENDPOINT=
|
||||||
|
THUMBOR_SECRET=
|
|
@ -7,3 +7,17 @@ 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]'
|
||||||
|
|
12
prisma/migrations/20240829084742_thumbnails/migration.sql
Normal file
12
prisma/migrations/20240829084742_thumbnails/migration.sql
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
-- 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;
|
10
prisma/migrations/20240829085742_thumbnails/migration.sql
Normal file
10
prisma/migrations/20240829085742_thumbnails/migration.sql
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
/*
|
||||||
|
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;
|
11
prisma/migrations/20240829095034_thumbnail_id/migration.sql
Normal file
11
prisma/migrations/20240829095034_thumbnail_id/migration.sql
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
/*
|
||||||
|
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,6 +58,7 @@ 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 {
|
||||||
|
@ -69,6 +70,13 @@ 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
|
||||||
|
|
35
src/lib/components/Upload.svelte
Normal file
35
src/lib/components/Upload.svelte
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
<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,7 +124,8 @@ export async function getUpload(id: string) {
|
||||||
internalName: true,
|
internalName: true,
|
||||||
public: true,
|
public: true,
|
||||||
uploaded: true,
|
uploaded: true,
|
||||||
uploader: true
|
uploader: true,
|
||||||
|
thumbnail: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
48
src/lib/server/thumbnail.ts
Normal file
48
src/lib/server/thumbnail.ts
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
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_KEY || '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,21 +21,7 @@
|
||||||
{#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}
|
||||||
<a
|
<Upload {upload} {i}></Upload>
|
||||||
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}
|
||||||
|
|
44
src/routes/api/v1/thumbnail/[id]/+server.ts
Normal file
44
src/routes/api/v1/thumbnail/[id]/+server.ts
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
53
src/routes/download/[id]/[ticket]/+server.ts
Normal file
53
src/routes/download/[id]/[ticket]/+server.ts
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
3
thumbor.Dockerfile
Normal file
3
thumbor.Dockerfile
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
FROM ghcr.io/minimalcompact/thumbor:latest
|
||||||
|
|
||||||
|
RUN pip install thumbor-video-engine
|
Loading…
Reference in a new issue