From 5a4ef883ab603f99f6eeb0535d73a8600c8ca729 Mon Sep 17 00:00:00 2001 From: Ivan Botygin Date: Thu, 20 Nov 2025 19:46:00 +0300 Subject: [PATCH] =?UTF-8?q?feat(saves):=20=D0=9F=D1=83=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BA=D0=B0=D1=80=D1=82=D0=B8?= =?UTF-8?q?=D0=BD=D0=BE=D0=BA=20=D0=B8=D0=B7=20=D1=84=D0=B0=D0=B9=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=B8=20=D0=BF=D0=BE=20=D0=B2=D0=BD=D0=B5=D1=88?= =?UTF-8?q?=D0=BD=D0=B8=D0=BC=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B0=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/saves.controller.ts | 104 ++++++++++++++++++ apps/backend/src/services/saves.service.ts | 104 +++++++++++++++++- 2 files changed, 207 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/controllers/saves.controller.ts b/apps/backend/src/controllers/saves.controller.ts index 163d750..02dc9b7 100644 --- a/apps/backend/src/controllers/saves.controller.ts +++ b/apps/backend/src/controllers/saves.controller.ts @@ -173,4 +173,108 @@ export const savesController = new Elysia({ prefix: '/saves' }) description: 'Redirects to a presigned URL for downloading the file', }, } + ) + + .post( + '/external', + async ({ body, user, set }) => { + if (!user) { + set.status = 401; + return { error: 'Unauthorized' }; + } + + try { + const save = await savesService.createFromUrl(user.id, body); + + return { + id: save.id, + name: save.name, + type: save.type, + url: save.url, + visibility: save.visibility, + shareUrl: save.visibility === 'link' ? save.shareUrl : undefined, + createdAt: save.createdAt.toISOString(), + }; + } catch (error) { + console.error('Error creating save from URL:', error); + const errorMessage = error instanceof Error ? error.message : 'Failed to create save from URL'; + + if (errorMessage.includes('Unsupported') || errorMessage.includes('not found')) { + set.status = 400; + } else if (errorMessage.includes('Failed to download') || errorMessage.includes('Failed to scrape')) { + set.status = 502; + } else { + set.status = 500; + } + + return { error: errorMessage }; + } + }, + { + body: t.Object({ + url: t.String({ format: 'uri' }), + name: t.Optional(t.String()), + description: t.Optional(t.String()), + tags: t.Optional(t.Array(t.String())), + visibility: t.Optional(t.Union([t.Literal('public'), t.Literal('link')])), + }), + detail: { + tags: ['Saves'], + summary: 'Create save from external URL', + description: 'Downloads and saves a media file from external source', + }, + auth: true + } + ) + + .post( + '/upload', + async ({ body, user, set }) => { + if (!user) { + set.status = 401; + return { error: 'Unauthorized' }; + } + + try { + const { file, name, description, tags, visibility } = body; + + const save = await savesService.createFromFile(user.id, file, { + name, + description, + tags, + visibility, + }); + + return { + id: save.id, + name: save.name, + type: save.type, + url: save.url, + visibility: save.visibility, + shareUrl: save.visibility === 'link' ? save.shareUrl : undefined, + createdAt: save.createdAt.toISOString(), + }; + } catch (error) { + set.status = 500; + return { + error: error instanceof Error ? error.message : 'Failed to upload file' + }; + } + }, + { + body: t.Object({ + file: t.File(), + name: t.Optional(t.String()), + description: t.Optional(t.String()), + tags: t.Optional(t.Array(t.String())), + visibility: t.Optional(t.Union([t.Literal('public'), t.Literal('link')])), + }), + type: 'multipart/form-data', + detail: { + tags: ['Saves'], + summary: 'Upload file', + description: 'Uploads a media file from device', + }, + auth: true + } ); diff --git a/apps/backend/src/services/saves.service.ts b/apps/backend/src/services/saves.service.ts index 03b3aae..e1a0de4 100644 --- a/apps/backend/src/services/saves.service.ts +++ b/apps/backend/src/services/saves.service.ts @@ -1,10 +1,103 @@ import { eq, and, desc } from 'drizzle-orm'; -import { db, save, type Save } from '@/db'; +import { db, save, type Save, type NewSave } from '@/db'; +import { s3Service } from './s3.service'; +import { scraperService } from './scraper.service'; import { redis } from './redis.service'; +import { nanoid } from 'nanoid'; + +import type { + Visibility, + CreateSaveFromUrlRequest, +} from '@p1ctos4ve/shared-types'; class SavesService { private readonly CACHE_TTL = 3600; + async createFromFile( + userId: string, + file: File, + metadata: { + name?: string; + description?: string; + tags?: string[]; + visibility?: Visibility; + } + ): Promise { + const mimeType = file.type; + const fileType = s3Service.getFileType(mimeType); + + if (fileType === 'unknown') { + throw new Error('Unsupported file type'); + } + + const { key, url, size } = await s3Service.uploadFile(file, userId, mimeType); + + const shareUrl = + metadata.visibility === 'link' ? this.generateShareUrl() : undefined; + + const newSave: NewSave = { + userId, + name: metadata.name || file.name || 'Untitled', + description: metadata.description || '', + type: fileType, + tags: metadata.tags || [], + visibility: metadata.visibility || 'link', + shareUrl, + s3Key: key, + url, + fileSize: size, + mimeType, + }; + + const [savedItem] = await db.insert(save).values(newSave).returning(); + + await this.invalidateUserCache(userId); + + return savedItem; + } + + async createFromUrl( + userId: string, + data: CreateSaveFromUrlRequest + ): Promise { + const scrapedMedia = await scraperService.scrapeUrl(data.url); + + const { buffer, mimeType, size } = await s3Service.downloadFromUrl( + scrapedMedia.url + ); + + const fileType = s3Service.getFileType(mimeType); + + if (fileType === 'unknown') { + throw new Error('Unsupported file type'); + } + + const { key, url } = await s3Service.uploadFile(buffer, userId, mimeType); + + const shareUrl = + data.visibility === 'link' ? this.generateShareUrl() : undefined; + + const newSave: NewSave = { + userId, + name: data.name || scrapedMedia.title || 'Untitled', + description: data.description || scrapedMedia.description || '', + type: fileType, + tags: data.tags || [], + visibility: data.visibility || 'link', + shareUrl, + s3Key: key, + url, + fileSize: size, + mimeType, + }; + + const [savedItem] = await db.insert(save).values(newSave).returning(); + + await this.invalidateUserCache(userId); + + return savedItem; + } + async getById( id: number, requestUserId?: string, @@ -87,6 +180,15 @@ class SavesService { return false; } + + private generateShareUrl(): string { + return nanoid(16); + } + + private async invalidateUserCache(userId: string): Promise { + await redis.del(`user_saves:${userId}`); + await redis.del(`public_saves:${userId}`); + } } export const savesService = new SavesService();