THUMBNAILING

This commit is contained in:
cirroskais 2024-08-29 06:24:27 -04:00
parent a927530fe5
commit 1681d04a4b
No known key found for this signature in database
GPG key ID: 5FC73EBF2678E33D
13 changed files with 257 additions and 22 deletions

View file

@ -1 +1,11 @@
BASE_URL=
DATABASE_URL=
BODY_SIZE_LIMIT=
MINIO_URL=
MINIO_ACCESS_KEY=
MINIO_SECRET_KEY=
MINIO_BUCKET=
THUMBOR_ENDPOINT=
THUMBOR_SECRET=

View file

@ -7,3 +7,17 @@ services:
MARIADB_ROOT_PASSWORD: development
ports:
- '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]'

View 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;

View 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;

View 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`);

View file

@ -58,6 +58,7 @@ model Upload {
size Int
public Boolean @default(true)
uploaded DateTime @default(now())
thumbnail Thumbnail?
}
model APIKey {
@ -69,6 +70,13 @@ model APIKey {
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 {
ADMINISTRATOR
USER

View 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>

View file

@ -124,7 +124,8 @@ export async function getUpload(id: string) {
internalName: true,
public: true,
uploaded: true,
uploader: true
uploader: true,
thumbnail: true
}
});
}

View 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;
}

View file

@ -1,11 +1,11 @@
<script lang="ts">
import { File, ChevronLeft, ChevronRight } from 'lucide-svelte';
import { bytesToHumanReadable } from '$lib';
import { fade } from 'svelte/transition';
import { writable, get } from 'svelte/store';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { page } from '$app/stores';
import Upload from '$lib/components/Upload.svelte';
export let data;
@ -21,21 +21,7 @@
{#if uploads.length}
<div class="grid grid-cols-5 grid-rows-3 gap-2">
{#each uploads as upload, i}
<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>
<Upload {upload} {i}></Upload>
{/each}
</div>
{:else}

View 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()
}
});
};

View 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
View file

@ -0,0 +1,3 @@
FROM ghcr.io/minimalcompact/thumbor:latest
RUN pip install thumbor-video-engine