mirror of
https://github.com/immich-app/immich.git
synced 2024-12-28 06:31:58 +00:00
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:
parent
ac2a36bd53
commit
ed4358741e
12 changed files with 160 additions and 25 deletions
2
server/.gitignore
vendored
2
server/.gitignore
vendored
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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();
|
||||||
|
|
||||||
|
|
14
server/test/fixtures/auth.stub.ts
vendored
14
server/test/fixtures/auth.stub.ts
vendored
|
@ -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 = {
|
||||||
|
|
|
@ -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%
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
Loading…
Reference in a new issue