feat(web): re-add open graph tags for public share links (#5635)

* feat: re-add open graph tags for public share links

* fix: undefined in html

* chore: tests
This commit is contained in:
Jason Rasmussen 2023-12-11 14:37:47 -05:00 committed by GitHub
parent ac2a36bd53
commit ed4358741e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 160 additions and 25 deletions

2
server/.gitignore vendored
View File

@ -11,6 +11,8 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
lerna-debug.log* lerna-debug.log*
www/
# OS # OS
.DS_Store .DS_Store

View File

@ -371,7 +371,7 @@ export class AuthService {
return cookies[IMMICH_ACCESS_COOKIE] || null; return cookies[IMMICH_ACCESS_COOKIE] || null;
} }
private async validateSharedLink(key: string | string[]): Promise<AuthDto> { async validateSharedLink(key: string | string[]): Promise<AuthDto> {
key = Array.isArray(key) ? key[0] : key; key = Array.isArray(key) ? key[0] : key;
const bytes = Buffer.from(key, key.length === 100 ? 'hex' : 'base64url'); const bytes = Buffer.from(key, key.length === 100 ? 'hex' : 'base64url');

View File

@ -16,6 +16,12 @@ import { CronJob } from 'cron';
import { basename, extname } from 'node:path'; import { basename, extname } from 'node:path';
import sanitize from 'sanitize-filename'; import sanitize from 'sanitize-filename';
export interface OpenGraphTags {
title: string;
description: string;
imageUrl?: string;
}
export type Options = { export type Options = {
optional?: boolean; optional?: boolean;
each?: boolean; each?: boolean;

View File

@ -256,4 +256,27 @@ describe(SharedLinkService.name, () => {
expect(shareMock.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [] }); 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();
});
});
}); });

View File

@ -3,6 +3,7 @@ import { BadRequestException, ForbiddenException, Inject, Injectable, Unauthoriz
import { AccessCore, Permission } from '../access'; import { AccessCore, Permission } from '../access';
import { AssetIdErrorReason, AssetIdsDto, AssetIdsResponseDto } from '../asset'; import { AssetIdErrorReason, AssetIdsDto, AssetIdsResponseDto } from '../asset';
import { AuthDto } from '../auth'; import { AuthDto } from '../auth';
import { OpenGraphTags } from '../domain.util';
import { IAccessRepository, ICryptoRepository, ISharedLinkRepository } from '../repositories'; import { IAccessRepository, ICryptoRepository, ISharedLinkRepository } from '../repositories';
import { SharedLinkResponseDto, mapSharedLink, mapSharedLinkWithoutMetadata } from './shared-link-response.dto'; import { SharedLinkResponseDto, mapSharedLink, mapSharedLinkWithoutMetadata } from './shared-link-response.dto';
import { SharedLinkCreateDto, SharedLinkEditDto, SharedLinkPasswordDto } from './shared-link.dto'; import { SharedLinkCreateDto, SharedLinkEditDto, SharedLinkPasswordDto } from './shared-link.dto';
@ -28,7 +29,7 @@ export class SharedLinkService {
throw new ForbiddenException(); 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 }); const response = this.map(sharedLink, { withExif: sharedLink.showExif });
if (sharedLink.password) { if (sharedLink.password) {
response.token = this.validateAndRefreshToken(sharedLink, dto); response.token = this.validateAndRefreshToken(sharedLink, dto);
@ -38,7 +39,7 @@ export class SharedLinkService {
} }
async get(auth: AuthDto, id: string): Promise<SharedLinkResponseDto> { async get(auth: AuthDto, id: string): Promise<SharedLinkResponseDto> {
const sharedLink = await this.findOrFail(auth, id); const sharedLink = await this.findOrFail(auth.user.id, id);
return this.map(sharedLink, { withExif: true }); return this.map(sharedLink, { withExif: true });
} }
@ -79,7 +80,7 @@ export class SharedLinkService {
} }
async update(auth: AuthDto, id: string, dto: SharedLinkEditDto) { 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({ const sharedLink = await this.repository.update({
id, id,
userId: auth.user.id, userId: auth.user.id,
@ -94,12 +95,13 @@ export class SharedLinkService {
} }
async remove(auth: AuthDto, id: string): Promise<void> { async remove(auth: AuthDto, id: string): Promise<void> {
const sharedLink = await this.findOrFail(auth, id); const sharedLink = await this.findOrFail(auth.user.id, id);
await this.repository.remove(sharedLink); await this.repository.remove(sharedLink);
} }
private async findOrFail(auth: AuthDto, id: string) { // TODO: replace `userId` with permissions and access control checks
const sharedLink = await this.repository.get(auth.user.id, id); private async findOrFail(userId: string, id: string) {
const sharedLink = await this.repository.get(userId, id);
if (!sharedLink) { if (!sharedLink) {
throw new BadRequestException('Shared link not found'); throw new BadRequestException('Shared link not found');
} }
@ -107,7 +109,7 @@ export class SharedLinkService {
} }
async addAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> { async addAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
const sharedLink = await this.findOrFail(auth, id); const sharedLink = await this.findOrFail(auth.user.id, id);
if (sharedLink.type !== SharedLinkType.INDIVIDUAL) { if (sharedLink.type !== SharedLinkType.INDIVIDUAL) {
throw new BadRequestException('Invalid shared link type'); throw new BadRequestException('Invalid shared link type');
@ -141,7 +143,7 @@ export class SharedLinkService {
} }
async removeAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> { async removeAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
const sharedLink = await this.findOrFail(auth, id); const sharedLink = await this.findOrFail(auth.user.id, id);
if (sharedLink.type !== SharedLinkType.INDIVIDUAL) { if (sharedLink.type !== SharedLinkType.INDIVIDUAL) {
throw new BadRequestException('Invalid shared link type'); throw new BadRequestException('Invalid shared link type');
@ -164,6 +166,24 @@ export class SharedLinkService {
return results; return results;
} }
async getMetadataTags(auth: AuthDto): Promise<null | OpenGraphTags> {
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 }) { private map(sharedLink: SharedLinkEntity, { withExif }: { withExif: boolean }) {
return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithoutMetadata(sharedLink); return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithoutMetadata(sharedLink);
} }

View File

@ -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 { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression, Interval } from '@nestjs/schedule'; 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 name="description" content="${meta.description}" />
<!-- Facebook Meta Tags -->
<meta property="og:type" content="website" />
<meta property="og:title" content="${meta.title}" />
<meta property="og:description" content="${meta.description}" />
${meta.imageUrl ? `<meta property="og:image" content="${meta.imageUrl}" />` : ''}
<!-- Twitter Meta Tags -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="${meta.title}" />
<meta name="twitter:description" content="${meta.description}" />
${meta.imageUrl ? `<meta name="twitter:image" content="${meta.imageUrl}" />` : ''}`;
return index.replace('<!-- metadata:tags -->', tags);
};
@Injectable() @Injectable()
export class AppService { export class AppService {
private logger = new Logger(AppService.name); private logger = new Logger(AppService.name);
constructor( constructor(
private authService: AuthService,
private jobService: JobService, private jobService: JobService,
private libraryService: LibraryService,
private storageService: StorageService,
private serverService: ServerInfoService, private serverService: ServerInfoService,
private sharedLinkService: SharedLinkService,
private storageService: StorageService,
) {} ) {}
@Interval(ONE_HOUR.as('milliseconds')) @Interval(ONE_HOUR.as('milliseconds'))
@ -28,4 +59,47 @@ export class AppService {
await this.serverService.handleVersionCheck(); await this.serverService.handleVersionCheck();
this.logger.log(`Feature Flags: ${JSON.stringify(await this.serverService.getFeatures(), null, 2)}`); 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);
};
}
} }

View File

@ -13,7 +13,6 @@ import {
SwaggerDocumentOptions, SwaggerDocumentOptions,
SwaggerModule, SwaggerModule,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { NextFunction, Request, Response } from 'express';
import { writeFileSync } from 'fs'; import { writeFileSync } from 'fs';
import path from 'path'; import path from 'path';
@ -101,14 +100,6 @@ const patchOpenAPI = (document: OpenAPIObject) => {
return document; 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) => { export const useSwagger = (app: INestApplication, isDev: boolean) => {
const config = new DocumentBuilder() const config = new DocumentBuilder()
.setTitle('Immich') .setTitle('Immich')

View File

@ -6,7 +6,8 @@ import { NestExpressApplication } from '@nestjs/platform-express';
import { json } from 'body-parser'; import { json } from 'body-parser';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import { AppModule } from './app.module'; 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 logger = new Logger('ImmichServer');
const port = Number(process.env.SERVER_PORT) || 3001; const port = Number(process.env.SERVER_PORT) || 3001;
@ -27,7 +28,7 @@ export async function bootstrap() {
const excludePaths = ['/.well-known/immich', '/custom.css']; const excludePaths = ['/.well-known/immich', '/custom.css'];
app.setGlobalPrefix('api', { exclude: excludePaths }); app.setGlobalPrefix('api', { exclude: excludePaths });
app.useStaticAssets('www'); app.useStaticAssets('www');
app.use(indexFallback(excludePaths)); app.use(app.get(AppService).ssr(excludePaths));
await enablePrefilter(); await enablePrefilter();

View File

@ -104,6 +104,20 @@ export const authStub = {
showExif: true, showExif: true,
} as SharedLinkEntity, } as SharedLinkEntity,
}), }),
passwordSharedLink: Object.freeze<AuthDto>({
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 = { export const loginResponseStub = {

View File

@ -1,6 +1,9 @@
<!doctype html> <!doctype html>
<html lang="en" class="dark"> <html lang="en" class="dark">
<head> <head>
<!-- (used for SSR) -->
<!-- metadata:tags -->
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head% %sveltekit.head%

View File

@ -1,4 +1,3 @@
import featurePanelUrl from '$lib/assets/feature-panel.png';
import { getAuthUser } from '$lib/utils/auth'; import { getAuthUser } from '$lib/utils/auth';
import { api, ThumbnailFormat } from '@api'; import { api, ThumbnailFormat } from '@api';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
@ -21,7 +20,9 @@ export const load = (async ({ params }) => {
meta: { meta: {
title: sharedLink.album ? sharedLink.album.albumName : 'Public Share', title: sharedLink.album ? sharedLink.album.albumName : 'Public Share',
description: sharedLink.description || `${assetCount} shared photos & videos.`, 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) { } catch (e) {

View File

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB