Compare commits

..

1 Commits

Author SHA1 Message Date
Alex Tran 476e73b0c5 feat: command for user pages 2026-05-21 23:05:34 -05:00
4 changed files with 146 additions and 42 deletions
@@ -1,16 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// Delete unauthorized cross-owner asset faces
await sql`
DELETE FROM asset_face
USING person, asset
WHERE asset_face."personId" = person.id
AND asset_face."assetId" = asset.id
AND person."ownerId" <> asset."ownerId"
`.execute(db);
}
export async function down(): Promise<void> {
// Not implemented: the deleted rows were unauthorized cross-owner entries
}
@@ -454,30 +454,6 @@ describe(PersonService.name, () => {
expect(mocks.person.update).not.toHaveBeenCalled(); expect(mocks.person.update).not.toHaveBeenCalled();
expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(mocks.job.queueAll).not.toHaveBeenCalled();
}); });
it('should reject creating a face on an asset the user does not own', async () => {
const auth = AuthFactory.create();
const asset = AssetFactory.create();
const person = PersonFactory.create({ faceAssetId: null });
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set());
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
await expect(
sut.createFace(auth, {
assetId: asset.id,
personId: person.id,
imageHeight: 500,
imageWidth: 400,
x: 10,
y: 20,
width: 100,
height: 110,
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.person.createAssetFace).not.toHaveBeenCalled();
});
}); });
describe('createNewFeaturePhoto', () => { describe('createNewFeaturePhoto', () => {
+1 -1
View File
@@ -627,7 +627,7 @@ export class PersonService extends BaseService {
// TODO return a asset face response // TODO return a asset face response
async createFace(auth: AuthDto, dto: AssetFaceCreateDto): Promise<void> { async createFace(auth: AuthDto, dto: AssetFaceCreateDto): Promise<void> {
await Promise.all([ await Promise.all([
this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [dto.assetId] }), this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.assetId] }),
this.requireAccess({ auth, permission: Permission.PersonRead, ids: [dto.personId] }), this.requireAccess({ auth, permission: Permission.PersonRead, ids: [dto.personId] }),
]); ]);
+145 -1
View File
@@ -1,17 +1,35 @@
import { defaultProvider, screencastManager, themeManager, ThemePreference, type ActionItem } from '@immich/ui'; import { defaultProvider, screencastManager, themeManager, ThemePreference, type ActionItem } from '@immich/ui';
import { import {
mdiAccountMultipleOutline, mdiAccountMultipleOutline,
mdiAccountOutline,
mdiArchiveArrowDownOutline,
mdiBookshelf, mdiBookshelf,
mdiCog, mdiCog,
mdiContentDuplicate,
mdiCrosshairsGps,
mdiFolderOutline,
mdiHeartOutline,
mdiImageAlbum,
mdiImageMultipleOutline,
mdiImageSizeSelectLarge,
mdiKeyboard, mdiKeyboard,
mdiLink,
mdiLockOutline,
mdiMagnify,
mdiMapOutline,
mdiServer, mdiServer,
mdiStateMachine,
mdiSync, mdiSync,
mdiTagMultipleOutline,
mdiThemeLightDark, mdiThemeLightDark,
mdiToolboxOutline,
mdiTrashCanOutline,
} from '@mdi/js'; } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n'; import type { MessageFormatter } from 'svelte-i18n';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { copyToClipboard } from '$lib/utils'; import { copyToClipboard } from '$lib/utils';
@@ -49,7 +67,133 @@ export const getPagesProvider = ($t: MessageFormatter) => {
}, },
].map((route) => ({ ...route, $if: () => authManager.authenticated && authManager.user.isAdmin })); ].map((route) => ({ ...route, $if: () => authManager.authenticated && authManager.user.isAdmin }));
return defaultProvider({ name: $t('page'), actions: adminPages }); const userPages: ActionItem[] = [
{
title: $t('photos'),
icon: mdiImageMultipleOutline,
onAction: () => goto(Route.photos()),
},
{
title: $t('explore'),
icon: mdiMagnify,
onAction: () => goto(Route.explore()),
$if: () => authManager.authenticated && featureFlagsManager.value.search,
},
{
title: $t('map'),
icon: mdiMapOutline,
onAction: () => goto(Route.map()),
$if: () => authManager.authenticated && featureFlagsManager.value.map,
},
{
title: $t('people'),
description: $t('people_feature_description'),
icon: mdiAccountOutline,
onAction: () => goto(Route.people()),
$if: () => authManager.authenticated && authManager.preferences.people.enabled,
},
{
title: $t('shared_links'),
icon: mdiLink,
onAction: () => goto(Route.sharedLinks()),
$if: () => authManager.authenticated && authManager.preferences.sharedLinks.enabled,
},
{
title: $t('recently_added'),
icon: mdiMagnify,
onAction: () => goto(Route.recentlyAdded()),
$if: () => authManager.authenticated,
},
{
title: $t('sharing'),
icon: mdiAccountMultipleOutline,
onAction: () => goto(Route.sharing()),
$if: () => authManager.authenticated,
},
{
title: $t('favorites'),
icon: mdiHeartOutline,
onAction: () => goto(Route.favorites()),
$if: () => authManager.authenticated,
},
{
title: $t('albums'),
description: $t('albums_feature_description'),
icon: mdiImageAlbum,
onAction: () => goto(Route.albums()),
$if: () => authManager.authenticated,
},
{
title: $t('tags'),
description: $t('tag_feature_description'),
icon: mdiTagMultipleOutline,
onAction: () => goto(Route.tags()),
$if: () => authManager.authenticated && authManager.preferences.tags.enabled,
},
{
title: $t('folders'),
description: $t('folders_feature_description'),
icon: mdiFolderOutline,
onAction: () => goto(Route.folders()),
$if: () => authManager.authenticated && authManager.preferences.folders.enabled,
},
{
title: $t('utilities'),
icon: mdiToolboxOutline,
onAction: () => goto(Route.utilities()),
$if: () => authManager.authenticated,
},
{
title: $t('archive'),
icon: mdiArchiveArrowDownOutline,
onAction: () => goto(Route.archive()),
$if: () => authManager.authenticated,
},
{
title: $t('locked_folder'),
icon: mdiLockOutline,
onAction: () => goto(Route.locked()),
$if: () => authManager.authenticated,
},
{
title: $t('trash'),
icon: mdiTrashCanOutline,
onAction: () => goto(Route.trash()),
$if: () => authManager.authenticated && featureFlagsManager.value.trash,
},
{
title: $t('admin.user_settings'),
icon: mdiCog,
onAction: () => goto(Route.userSettings()),
$if: () => authManager.authenticated,
},
].map((route) => ({ $if: () => authManager.authenticated, ...route }));
const utilityPages: ActionItem[] = [
{
title: $t('review_duplicates'),
icon: mdiContentDuplicate,
onAction: () => goto(Route.duplicatesUtility()),
},
{
title: $t('review_large_files'),
icon: mdiImageSizeSelectLarge,
onAction: () => goto(Route.largeFileUtility()),
},
{
title: $t('manage_geolocation'),
icon: mdiCrosshairsGps,
onAction: () => goto(Route.geolocationUtility()),
},
{
title: $t('workflows'),
icon: mdiStateMachine,
onAction: () => goto(Route.workflows()),
},
].map((route) => ({ ...route, $if: () => authManager.authenticated }));
return defaultProvider({ name: $t('page'), actions: [...userPages, ...utilityPages, ...adminPages] });
}; };
const getMyImmichLink = () => { const getMyImmichLink = () => {