diff --git a/server/.gitignore b/server/.gitignore index 4f2bbcf8a9..4a66f04b4f 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -11,6 +11,8 @@ yarn-debug.log* yarn-error.log* lerna-debug.log* +www/ + # OS .DS_Store diff --git a/server/src/domain/auth/auth.service.ts b/server/src/domain/auth/auth.service.ts index b092ea7d31..a18b312ba3 100644 --- a/server/src/domain/auth/auth.service.ts +++ b/server/src/domain/auth/auth.service.ts @@ -371,7 +371,7 @@ export class AuthService { return cookies[IMMICH_ACCESS_COOKIE] || null; } - private async validateSharedLink(key: string | string[]): Promise { + async validateSharedLink(key: string | string[]): Promise { key = Array.isArray(key) ? key[0] : key; const bytes = Buffer.from(key, key.length === 100 ? 'hex' : 'base64url'); diff --git a/server/src/domain/domain.util.ts b/server/src/domain/domain.util.ts index 890ea3a8b5..5ef6da6a94 100644 --- a/server/src/domain/domain.util.ts +++ b/server/src/domain/domain.util.ts @@ -16,6 +16,12 @@ import { CronJob } from 'cron'; import { basename, extname } from 'node:path'; import sanitize from 'sanitize-filename'; +export interface OpenGraphTags { + title: string; + description: string; + imageUrl?: string; +} + export type Options = { optional?: boolean; each?: boolean; diff --git a/server/src/domain/shared-link/shared-link.service.spec.ts b/server/src/domain/shared-link/shared-link.service.spec.ts index cb50f9ba57..6d95d2831f 100644 --- a/server/src/domain/shared-link/shared-link.service.spec.ts +++ b/server/src/domain/shared-link/shared-link.service.spec.ts @@ -256,4 +256,27 @@ describe(SharedLinkService.name, () => { expect(shareMock.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [] }); }); }); + + describe('getMetadataTags', () => { + it('should return null when auth is not a shared link', async () => { + await expect(sut.getMetadataTags(authStub.admin)).resolves.toBe(null); + expect(shareMock.get).not.toHaveBeenCalled(); + }); + + it('should return null when shared link has a password', async () => { + await expect(sut.getMetadataTags(authStub.passwordSharedLink)).resolves.toBe(null); + expect(shareMock.get).not.toHaveBeenCalled(); + }); + + it('should return metadata tags', async () => { + shareMock.get.mockResolvedValue(sharedLinkStub.individual); + await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({ + description: '1 shared photos & videos', + imageUrl: + '/api/asset/thumbnail/asset-id?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0', + title: 'Public Share', + }); + expect(shareMock.get).toHaveBeenCalled(); + }); + }); }); diff --git a/server/src/domain/shared-link/shared-link.service.ts b/server/src/domain/shared-link/shared-link.service.ts index ee53e95080..b2b488138f 100644 --- a/server/src/domain/shared-link/shared-link.service.ts +++ b/server/src/domain/shared-link/shared-link.service.ts @@ -3,6 +3,7 @@ import { BadRequestException, ForbiddenException, Inject, Injectable, Unauthoriz import { AccessCore, Permission } from '../access'; import { AssetIdErrorReason, AssetIdsDto, AssetIdsResponseDto } from '../asset'; import { AuthDto } from '../auth'; +import { OpenGraphTags } from '../domain.util'; import { IAccessRepository, ICryptoRepository, ISharedLinkRepository } from '../repositories'; import { SharedLinkResponseDto, mapSharedLink, mapSharedLinkWithoutMetadata } from './shared-link-response.dto'; import { SharedLinkCreateDto, SharedLinkEditDto, SharedLinkPasswordDto } from './shared-link.dto'; @@ -28,7 +29,7 @@ export class SharedLinkService { throw new ForbiddenException(); } - const sharedLink = await this.findOrFail(auth, auth.sharedLink.id); + const sharedLink = await this.findOrFail(auth.user.id, auth.sharedLink.id); const response = this.map(sharedLink, { withExif: sharedLink.showExif }); if (sharedLink.password) { response.token = this.validateAndRefreshToken(sharedLink, dto); @@ -38,7 +39,7 @@ export class SharedLinkService { } async get(auth: AuthDto, id: string): Promise { - const sharedLink = await this.findOrFail(auth, id); + const sharedLink = await this.findOrFail(auth.user.id, id); return this.map(sharedLink, { withExif: true }); } @@ -79,7 +80,7 @@ export class SharedLinkService { } async update(auth: AuthDto, id: string, dto: SharedLinkEditDto) { - await this.findOrFail(auth, id); + await this.findOrFail(auth.user.id, id); const sharedLink = await this.repository.update({ id, userId: auth.user.id, @@ -94,12 +95,13 @@ export class SharedLinkService { } async remove(auth: AuthDto, id: string): Promise { - const sharedLink = await this.findOrFail(auth, id); + const sharedLink = await this.findOrFail(auth.user.id, id); await this.repository.remove(sharedLink); } - private async findOrFail(auth: AuthDto, id: string) { - const sharedLink = await this.repository.get(auth.user.id, id); + // TODO: replace `userId` with permissions and access control checks + private async findOrFail(userId: string, id: string) { + const sharedLink = await this.repository.get(userId, id); if (!sharedLink) { throw new BadRequestException('Shared link not found'); } @@ -107,7 +109,7 @@ export class SharedLinkService { } async addAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise { - const sharedLink = await this.findOrFail(auth, id); + const sharedLink = await this.findOrFail(auth.user.id, id); if (sharedLink.type !== SharedLinkType.INDIVIDUAL) { throw new BadRequestException('Invalid shared link type'); @@ -141,7 +143,7 @@ export class SharedLinkService { } async removeAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise { - const sharedLink = await this.findOrFail(auth, id); + const sharedLink = await this.findOrFail(auth.user.id, id); if (sharedLink.type !== SharedLinkType.INDIVIDUAL) { throw new BadRequestException('Invalid shared link type'); @@ -164,6 +166,24 @@ export class SharedLinkService { return results; } + async getMetadataTags(auth: AuthDto): Promise { + if (!auth.sharedLink || auth.sharedLink.password) { + return null; + } + + const sharedLink = await this.findOrFail(auth.sharedLink.userId, auth.sharedLink.id); + const assetId = sharedLink.album?.albumThumbnailAssetId || sharedLink.assets[0]?.id; + const assetCount = sharedLink.assets.length || sharedLink.album?.assets.length || 0; + + return { + title: sharedLink.album ? sharedLink.album.albumName : 'Public Share', + description: sharedLink.description || `${assetCount} shared photos & videos`, + imageUrl: assetId + ? `/api/asset/thumbnail/${assetId}?key=${sharedLink.key.toString('base64url')}` + : '/feature-panel.png', + }; + } + private map(sharedLink: SharedLinkEntity, { withExif }: { withExif: boolean }) { return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithoutMetadata(sharedLink); } diff --git a/server/src/immich/app.service.ts b/server/src/immich/app.service.ts index 0683b65515..4f6a47a482 100644 --- a/server/src/immich/app.service.ts +++ b/server/src/immich/app.service.ts @@ -1,16 +1,47 @@ -import { JobService, LibraryService, ONE_HOUR, ServerInfoService, StorageService } from '@app/domain'; +import { + AuthService, + JobService, + ONE_HOUR, + OpenGraphTags, + ServerInfoService, + SharedLinkService, + StorageService, +} from '@app/domain'; import { Injectable, Logger } from '@nestjs/common'; import { Cron, CronExpression, Interval } from '@nestjs/schedule'; +import { NextFunction, Request, Response } from 'express'; +import { readFileSync } from 'fs'; + +const render = (index: string, meta: OpenGraphTags) => { + const tags = ` + + + + + + + ${meta.imageUrl ? `` : ''} + + + + + + + ${meta.imageUrl ? `` : ''}`; + + return index.replace('', tags); +}; @Injectable() export class AppService { private logger = new Logger(AppService.name); constructor( + private authService: AuthService, private jobService: JobService, - private libraryService: LibraryService, - private storageService: StorageService, private serverService: ServerInfoService, + private sharedLinkService: SharedLinkService, + private storageService: StorageService, ) {} @Interval(ONE_HOUR.as('milliseconds')) @@ -28,4 +59,47 @@ export class AppService { await this.serverService.handleVersionCheck(); this.logger.log(`Feature Flags: ${JSON.stringify(await this.serverService.getFeatures(), null, 2)}`); } + + ssr(excludePaths: string[]) { + const index = readFileSync('/usr/src/app/www/index.html').toString(); + + return async (req: Request, res: Response, next: NextFunction) => { + if ( + req.url.startsWith('/api') || + req.method.toLowerCase() !== 'get' || + excludePaths.find((item) => req.url.startsWith(item)) + ) { + return next(); + } + + const targets = [ + { + regex: /^\/share\/(.+)$/, + onMatch: async (matches: RegExpMatchArray) => { + const key = matches[1]; + const auth = await this.authService.validateSharedLink(key); + return this.sharedLinkService.getMetadataTags(auth); + }, + }, + ]; + + let html = index; + + try { + for (const { regex, onMatch } of targets) { + const matches = req.url.match(regex); + if (matches) { + const meta = await onMatch(matches); + if (meta) { + html = render(index, meta); + } + + break; + } + } + } catch {} + + res.type('text/html').header('Cache-Control', 'no-store').send(html); + }; + } } diff --git a/server/src/immich/app.utils.ts b/server/src/immich/app.utils.ts index a667dce9f5..d7bbd25dbd 100644 --- a/server/src/immich/app.utils.ts +++ b/server/src/immich/app.utils.ts @@ -13,7 +13,6 @@ import { SwaggerDocumentOptions, SwaggerModule, } from '@nestjs/swagger'; -import { NextFunction, Request, Response } from 'express'; import { writeFileSync } from 'fs'; import path from 'path'; @@ -101,14 +100,6 @@ const patchOpenAPI = (document: OpenAPIObject) => { return document; }; -export const indexFallback = (excludePaths: string[]) => (req: Request, res: Response, next: NextFunction) => { - if (req.url.startsWith('/api') || req.method.toLowerCase() !== 'get' || excludePaths.indexOf(req.url) !== -1) { - next(); - } else { - res.sendFile('/www/index.html', { root: process.cwd() }); - } -}; - export const useSwagger = (app: INestApplication, isDev: boolean) => { const config = new DocumentBuilder() .setTitle('Immich') diff --git a/server/src/immich/main.ts b/server/src/immich/main.ts index 8711e11d10..afc3a41c6d 100644 --- a/server/src/immich/main.ts +++ b/server/src/immich/main.ts @@ -6,7 +6,8 @@ import { NestExpressApplication } from '@nestjs/platform-express'; import { json } from 'body-parser'; import cookieParser from 'cookie-parser'; import { AppModule } from './app.module'; -import { indexFallback, useSwagger } from './app.utils'; +import { AppService } from './app.service'; +import { useSwagger } from './app.utils'; const logger = new Logger('ImmichServer'); const port = Number(process.env.SERVER_PORT) || 3001; @@ -27,7 +28,7 @@ export async function bootstrap() { const excludePaths = ['/.well-known/immich', '/custom.css']; app.setGlobalPrefix('api', { exclude: excludePaths }); app.useStaticAssets('www'); - app.use(indexFallback(excludePaths)); + app.use(app.get(AppService).ssr(excludePaths)); await enablePrefilter(); diff --git a/server/test/fixtures/auth.stub.ts b/server/test/fixtures/auth.stub.ts index 1a24d8cc17..3dbbdcbf12 100644 --- a/server/test/fixtures/auth.stub.ts +++ b/server/test/fixtures/auth.stub.ts @@ -104,6 +104,20 @@ export const authStub = { showExif: true, } as SharedLinkEntity, }), + passwordSharedLink: Object.freeze({ + user: { + id: 'admin_id', + email: 'admin@test.com', + isAdmin: true, + } as UserEntity, + sharedLink: { + id: '123', + allowUpload: false, + allowDownload: false, + password: 'password-123', + showExif: true, + } as SharedLinkEntity, + }), }; export const loginResponseStub = { diff --git a/web/src/app.html b/web/src/app.html index 5f31df3336..8591848a0a 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -1,6 +1,9 @@ + + + %sveltekit.head% diff --git a/web/src/routes/(user)/share/[key]/+page.ts b/web/src/routes/(user)/share/[key]/+page.ts index 21604ed683..d23f393ca3 100644 --- a/web/src/routes/(user)/share/[key]/+page.ts +++ b/web/src/routes/(user)/share/[key]/+page.ts @@ -1,4 +1,3 @@ -import featurePanelUrl from '$lib/assets/feature-panel.png'; import { getAuthUser } from '$lib/utils/auth'; import { api, ThumbnailFormat } from '@api'; import { error } from '@sveltejs/kit'; @@ -21,7 +20,9 @@ export const load = (async ({ params }) => { meta: { title: sharedLink.album ? sharedLink.album.albumName : 'Public Share', description: sharedLink.description || `${assetCount} shared photos & videos.`, - imageUrl: assetId ? api.getAssetThumbnailUrl(assetId, ThumbnailFormat.Webp, sharedLink.key) : featurePanelUrl, + imageUrl: assetId + ? api.getAssetThumbnailUrl(assetId, ThumbnailFormat.Webp, sharedLink.key) + : '/feature-panel.png', }, }; } catch (e) { diff --git a/web/src/lib/assets/feature-panel.png b/web/static/feature-panel.png similarity index 100% rename from web/src/lib/assets/feature-panel.png rename to web/static/feature-panel.png