fix(server): restrict individual shared link asset removal to owners (#26868)

* fix(server): restrict individual shared link asset removal to owners

* make open-api
This commit is contained in:
Michel Heusschen
2026-03-12 19:48:00 +01:00
committed by GitHub
parent 6bb8f4fcc4
commit 3fd24e2083
8 changed files with 55 additions and 45 deletions

View File

@@ -438,6 +438,16 @@ describe('/shared-links', () => {
expect(body).toEqual(errorDto.badRequest('Invalid shared link type')); expect(body).toEqual(errorDto.badRequest('Invalid shared link type'));
}); });
it('should reject guests removing assets from an individual shared link', async () => {
const { status, body } = await request(app)
.delete(`/shared-links/${linkWithAssets.id}/assets`)
.query({ key: linkWithAssets.key })
.send({ assetIds: [asset1.id] });
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should remove assets from a shared link (individual)', async () => { it('should remove assets from a shared link (individual)', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.delete(`/shared-links/${linkWithAssets.id}/assets`) .delete(`/shared-links/${linkWithAssets.id}/assets`)

View File

@@ -12,15 +12,18 @@ import { asBearerAuth, utils } from 'src/utils';
test.describe('Shared Links', () => { test.describe('Shared Links', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;
let asset: AssetMediaResponseDto; let asset: AssetMediaResponseDto;
let asset2: AssetMediaResponseDto;
let album: AlbumResponseDto; let album: AlbumResponseDto;
let sharedLink: SharedLinkResponseDto; let sharedLink: SharedLinkResponseDto;
let sharedLinkPassword: SharedLinkResponseDto; let sharedLinkPassword: SharedLinkResponseDto;
let individualSharedLink: SharedLinkResponseDto;
test.beforeAll(async () => { test.beforeAll(async () => {
utils.initSdk(); utils.initSdk();
await utils.resetDatabase(); await utils.resetDatabase();
admin = await utils.adminSetup(); admin = await utils.adminSetup();
asset = await utils.createAsset(admin.accessToken); asset = await utils.createAsset(admin.accessToken);
asset2 = await utils.createAsset(admin.accessToken);
album = await createAlbum( album = await createAlbum(
{ {
createAlbumDto: { createAlbumDto: {
@@ -39,6 +42,10 @@ test.describe('Shared Links', () => {
albumId: album.id, albumId: album.id,
password: 'test-password', password: 'test-password',
}); });
individualSharedLink = await utils.createSharedLink(admin.accessToken, {
type: SharedLinkType.Individual,
assetIds: [asset.id, asset2.id],
});
}); });
test('download from a shared link', async ({ page }) => { test('download from a shared link', async ({ page }) => {
@@ -109,4 +116,21 @@ test.describe('Shared Links', () => {
await page.waitForURL('/photos'); await page.waitForURL('/photos');
await page.locator(`[data-asset-id="${asset.id}"]`).waitFor(); await page.locator(`[data-asset-id="${asset.id}"]`).waitFor();
}); });
test('owner can remove assets from an individual shared link', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/share/${individualSharedLink.key}`);
await page.locator(`[data-asset="${asset.id}"]`).waitFor();
await expect(page.locator(`[data-asset]`)).toHaveCount(2);
await page.locator(`[data-asset="${asset.id}"]`).hover();
await page.locator(`[data-asset="${asset.id}"] [role="checkbox"]`).click();
await page.getByRole('button', { name: 'Remove from shared link' }).click();
await page.getByRole('button', { name: 'Remove', exact: true }).click();
await expect(page.locator(`[data-asset="${asset.id}"]`)).toHaveCount(0);
await expect(page.locator(`[data-asset="${asset2.id}"]`)).toHaveCount(1);
});
}); });

View File

@@ -427,11 +427,7 @@ class SharedLinksApi {
/// * [String] id (required): /// * [String] id (required):
/// ///
/// * [AssetIdsDto] assetIdsDto (required): /// * [AssetIdsDto] assetIdsDto (required):
/// Future<Response> removeSharedLinkAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto,) async {
/// * [String] key:
///
/// * [String] slug:
Future<Response> removeSharedLinkAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto, { String? key, String? slug, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/shared-links/{id}/assets' final apiPath = r'/shared-links/{id}/assets'
.replaceAll('{id}', id); .replaceAll('{id}', id);
@@ -443,13 +439,6 @@ class SharedLinksApi {
final headerParams = <String, String>{}; final headerParams = <String, String>{};
final formParams = <String, String>{}; final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (slug != null) {
queryParams.addAll(_queryParams('', 'slug', slug));
}
const contentTypes = <String>['application/json']; const contentTypes = <String>['application/json'];
@@ -473,12 +462,8 @@ class SharedLinksApi {
/// * [String] id (required): /// * [String] id (required):
/// ///
/// * [AssetIdsDto] assetIdsDto (required): /// * [AssetIdsDto] assetIdsDto (required):
/// Future<List<AssetIdsResponseDto>?> removeSharedLinkAssets(String id, AssetIdsDto assetIdsDto,) async {
/// * [String] key: final response = await removeSharedLinkAssetsWithHttpInfo(id, assetIdsDto,);
///
/// * [String] slug:
Future<List<AssetIdsResponseDto>?> removeSharedLinkAssets(String id, AssetIdsDto assetIdsDto, { String? key, String? slug, }) async {
final response = await removeSharedLinkAssetsWithHttpInfo(id, assetIdsDto, key: key, slug: slug, );
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }

View File

@@ -11605,22 +11605,6 @@
"format": "uuid", "format": "uuid",
"type": "string" "type": "string"
} }
},
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "slug",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
} }
], ],
"requestBody": { "requestBody": {
@@ -11677,6 +11661,7 @@
"state": "Stable" "state": "Stable"
} }
], ],
"x-immich-permission": "sharedLink.update",
"x-immich-state": "Stable" "x-immich-state": "Stable"
}, },
"put": { "put": {

View File

@@ -5987,19 +5987,14 @@ export function updateSharedLink({ id, sharedLinkEditDto }: {
/** /**
* Remove assets from a shared link * Remove assets from a shared link
*/ */
export function removeSharedLinkAssets({ id, key, slug, assetIdsDto }: { export function removeSharedLinkAssets({ id, assetIdsDto }: {
id: string; id: string;
key?: string;
slug?: string;
assetIdsDto: AssetIdsDto; assetIdsDto: AssetIdsDto;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: AssetIdsResponseDto[]; data: AssetIdsResponseDto[];
}>(`/shared-links/${encodeURIComponent(id)}/assets${QS.query(QS.explode({ }>(`/shared-links/${encodeURIComponent(id)}/assets`, oazapfts.json({
key,
slug
}))}`, oazapfts.json({
...opts, ...opts,
method: "DELETE", method: "DELETE",
body: assetIdsDto body: assetIdsDto

View File

@@ -1,7 +1,8 @@
import { SharedLinkController } from 'src/controllers/shared-link.controller'; import { SharedLinkController } from 'src/controllers/shared-link.controller';
import { SharedLinkType } from 'src/enum'; import { Permission, SharedLinkType } from 'src/enum';
import { SharedLinkService } from 'src/services/shared-link.service'; import { SharedLinkService } from 'src/services/shared-link.service';
import request from 'supertest'; import request from 'supertest';
import { factory } from 'test/small.factory';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(SharedLinkController.name, () => { describe(SharedLinkController.name, () => {
@@ -31,4 +32,16 @@ describe(SharedLinkController.name, () => {
expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ expiresAt: null })); expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ expiresAt: null }));
}); });
}); });
describe('DELETE /shared-links/:id/assets', () => {
it('should require shared link update permission', async () => {
await request(ctx.getHttpServer()).delete(`/shared-links/${factory.uuid()}/assets`).send({ assetIds: [] });
expect(ctx.authenticate).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({ permission: Permission.SharedLinkUpdate, sharedLinkRoute: false }),
}),
);
});
});
}); });

View File

@@ -180,7 +180,7 @@ export class SharedLinkController {
} }
@Delete(':id/assets') @Delete(':id/assets')
@Authenticated({ sharedLink: true }) @Authenticated({ permission: Permission.SharedLinkUpdate })
@Endpoint({ @Endpoint({
summary: 'Remove assets from a shared link', summary: 'Remove assets from a shared link',
description: description:

View File

@@ -1,5 +1,4 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte'; import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import QrCodeModal from '$lib/modals/QrCodeModal.svelte'; import QrCodeModal from '$lib/modals/QrCodeModal.svelte';
@@ -138,7 +137,6 @@ export const handleRemoveSharedLinkAssets = async (sharedLink: SharedLinkRespons
try { try {
const results = await removeSharedLinkAssets({ const results = await removeSharedLinkAssets({
...authManager.params,
id: sharedLink.id, id: sharedLink.id,
assetIdsDto: { assetIds }, assetIdsDto: { assetIds },
}); });