From 5a4ef883ab603f99f6eeb0535d73a8600c8ca729 Mon Sep 17 00:00:00 2001 From: Ivan Botygin Date: Thu, 20 Nov 2025 19:46:00 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(saves):=20=D0=9F=D1=83=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BA=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=BA=20=D0=B8=D0=B7=20=D1=84=D0=B0=D0=B9?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=20=D0=B8=20=D0=BF=D0=BE=20=D0=B2=D0=BD=D0=B5?= =?UTF-8?q?=D1=88=D0=BD=D0=B8=D0=BC=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B0?= =?UTF-8?q?=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(); -- 2.49.0 From fd1e9018eeb2b2731f8c3ef966f79f56d6f87311 Mon Sep 17 00:00:00 2001 From: Ivan Botygin Date: Thu, 20 Nov 2025 20:24:12 +0300 Subject: [PATCH 2/3] =?UTF-8?q?feat(saves):=20=D0=9E=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D1=83=D1=89=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B2=D1=83=D1=8E=D1=89=D0=B8=D1=85=20=D0=BF=D1=83?= =?UTF-8?q?=D0=B1=D0=BB=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/saves.controller.ts | 57 +++++++++++++++++++ apps/backend/src/services/saves.service.ts | 47 +++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/apps/backend/src/controllers/saves.controller.ts b/apps/backend/src/controllers/saves.controller.ts index 02dc9b7..ecb840a 100644 --- a/apps/backend/src/controllers/saves.controller.ts +++ b/apps/backend/src/controllers/saves.controller.ts @@ -175,6 +175,63 @@ export const savesController = new Elysia({ prefix: '/saves' }) } ) + .patch( + '/:id', + async ({ params: { id }, body, user, set }) => { + if (!user) { + set.status = 401; + return { error: 'Unauthorized' }; + } + + const saveId = Number(id); + if (isNaN(saveId)) { + set.status = 400; + return { error: 'Invalid save ID' }; + } + + try { + const updated = await savesService.update(saveId, user.id, body); + + return { + id: updated.id, + name: updated.name, + type: updated.type, + description: updated.description, + tags: updated.tags, + visibility: updated.visibility, + shareUrl: updated.visibility === 'link' ? updated.shareUrl : undefined, + updatedAt: updated.updatedAt.toISOString(), + }; + } catch (error) { + if (error instanceof Error && error.message.includes('not found')) { + set.status = 404; + return { error: 'Save not found or access denied' }; + } + set.status = 500; + return { + error: error instanceof Error ? error.message : 'Failed to update save' + }; + } + }, + { + params: t.Object({ + id: t.String(), + }), + body: t.Object({ + 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: 'Update save', + description: 'Updates save metadata (owner only)', + }, + auth: true + } + ) + .post( '/external', async ({ body, user, set }) => { diff --git a/apps/backend/src/services/saves.service.ts b/apps/backend/src/services/saves.service.ts index e1a0de4..5999b8b 100644 --- a/apps/backend/src/services/saves.service.ts +++ b/apps/backend/src/services/saves.service.ts @@ -8,6 +8,7 @@ import { nanoid } from 'nanoid'; import type { Visibility, CreateSaveFromUrlRequest, + UpdateSaveRequest, } from '@p1ctos4ve/shared-types'; class SavesService { @@ -161,6 +162,47 @@ class SavesService { return publicSaves; } + async update( + id: number, + userId: string, + data: UpdateSaveRequest + ): Promise { + const savedItem = await this.getById(id, userId); + + if (!savedItem || savedItem.userId !== userId) { + throw new Error('Save not found or access denied'); + } + + const updateData: Partial = { + updatedAt: new Date(), + }; + + if (data.name !== undefined) updateData.name = data.name; + if (data.description !== undefined) updateData.description = data.description; + if (data.tags !== undefined) updateData.tags = data.tags; + if (data.visibility !== undefined) { + updateData.visibility = data.visibility; + + if (data.visibility === 'link' && !savedItem.shareUrl) { + updateData.shareUrl = this.generateShareUrl(); + } + + if (data.visibility === 'public') { + updateData.shareUrl = null; + } + } + + const [updated] = await db + .update(save) + .set(updateData) + .where(eq(save.id, id)) + .returning(); + + await this.invalidateCache(id, userId); + + return updated; + } + private hasAccess( savedItem: Save, requestUserId?: string, @@ -185,6 +227,11 @@ class SavesService { return nanoid(16); } + private async invalidateCache(saveId: number, userId: string): Promise { + await redis.del(`save:${saveId}`); + await this.invalidateUserCache(userId); + } + private async invalidateUserCache(userId: string): Promise { await redis.del(`user_saves:${userId}`); await redis.del(`public_saves:${userId}`); -- 2.49.0 From b8b56a4abb4a6dc5472bc68b967a0d871e327563 Mon Sep 17 00:00:00 2001 From: Ivan Botygin Date: Thu, 20 Nov 2025 21:14:14 +0300 Subject: [PATCH 3/3] =?UTF-8?q?feat(saves):=20=D0=A3=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/saves.controller.ts | 45 +++++++++++++++++++ apps/backend/src/services/saves.service.ts | 15 ++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/controllers/saves.controller.ts b/apps/backend/src/controllers/saves.controller.ts index ecb840a..0b4ff19 100644 --- a/apps/backend/src/controllers/saves.controller.ts +++ b/apps/backend/src/controllers/saves.controller.ts @@ -175,6 +175,51 @@ export const savesController = new Elysia({ prefix: '/saves' }) } ) + .delete( + '/:id', + async ({ params: { id }, user, set }) => { + if (!user) { + set.status = 401; + return { error: 'Unauthorized' }; + } + + const saveId = Number(id); + if (isNaN(saveId)) { + set.status = 400; + return { error: 'Invalid save ID' }; + } + + try { + await savesService.delete(saveId, user.id); + + return { + success: true, + message: 'Сейв успешно удален', + }; + } catch (error) { + if (error instanceof Error && error.message.includes('not found')) { + set.status = 404; + return { error: 'Save not found' }; + } + set.status = 500; + return { + error: error instanceof Error ? error.message : 'Failed to delete save' + }; + } + }, + { + params: t.Object({ + id: t.String(), + }), + detail: { + tags: ['Saves'], + summary: 'Delete save', + description: 'Deletes a save by ID (owner only)', + }, + auth: true + } + ) + .patch( '/:id', async ({ params: { id }, body, user, set }) => { diff --git a/apps/backend/src/services/saves.service.ts b/apps/backend/src/services/saves.service.ts index 5999b8b..f2085a8 100644 --- a/apps/backend/src/services/saves.service.ts +++ b/apps/backend/src/services/saves.service.ts @@ -4,7 +4,6 @@ import { s3Service } from './s3.service'; import { scraperService } from './scraper.service'; import { redis } from './redis.service'; import { nanoid } from 'nanoid'; - import type { Visibility, CreateSaveFromUrlRequest, @@ -203,6 +202,20 @@ class SavesService { return updated; } + async delete(id: number, userId: string): Promise { + const savedItem = await this.getById(id, userId); + + if (!savedItem || savedItem.userId !== userId) { + throw new Error('Save not found or access denied'); + } + + await s3Service.deleteFile(savedItem.s3Key); + + await db.delete(save).where(eq(save.id, id)); + + await this.invalidateCache(id, userId); + } + private hasAccess( savedItem: Save, requestUserId?: string, -- 2.49.0