mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge branch 'main' into feat/mobile-ocr
This commit is contained in:
@@ -35,9 +35,14 @@ class ActivityAccess {
|
||||
return this.db
|
||||
.selectFrom('activity')
|
||||
.select('activity.id')
|
||||
.leftJoin('album', (join) => join.onRef('activity.albumId', '=', 'album.id').on('album.deletedAt', 'is', null))
|
||||
.innerJoin('album', (join) => join.onRef('activity.albumId', '=', 'album.id').on('album.deletedAt', 'is', null))
|
||||
.innerJoin('album_user', (join) =>
|
||||
join
|
||||
.onRef('album.id', '=', 'album_user.albumId')
|
||||
.on('album_user.role', '=', sql.lit(AlbumUserRole.Owner))
|
||||
.on('album_user.userId', '=', asUuid(userId)),
|
||||
)
|
||||
.where('activity.id', 'in', [...activityIds])
|
||||
.whereRef('album.ownerId', '=', asUuid(userId))
|
||||
.execute()
|
||||
.then((activities) => new Set(activities.map((activity) => activity.id)));
|
||||
}
|
||||
@@ -52,11 +57,11 @@ class ActivityAccess {
|
||||
return this.db
|
||||
.selectFrom('album')
|
||||
.select('album.id')
|
||||
.leftJoin('album_user as albumUsers', 'albumUsers.albumId', 'album.id')
|
||||
.leftJoin('user', (join) => join.onRef('user.id', '=', 'albumUsers.userId').on('user.deletedAt', 'is', null))
|
||||
.innerJoin('album_user as albumUsers', 'albumUsers.albumId', 'album.id')
|
||||
.innerJoin('user', (join) => join.onRef('user.id', '=', 'albumUsers.userId').on('user.deletedAt', 'is', null))
|
||||
.where('album.id', 'in', [...albumIds])
|
||||
.where('album.isActivityEnabled', '=', true)
|
||||
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('user.id', '=', userId)]))
|
||||
.where((eb) => eb('user.id', '=', userId))
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.execute()
|
||||
.then((albums) => new Set(albums.map((album) => album.id)));
|
||||
@@ -77,7 +82,12 @@ class AlbumAccess {
|
||||
.selectFrom('album')
|
||||
.select('album.id')
|
||||
.where('album.id', 'in', [...albumIds])
|
||||
.where('album.ownerId', '=', userId)
|
||||
.innerJoin('album_user', (join) =>
|
||||
join
|
||||
.onRef('album.id', '=', 'album_user.albumId')
|
||||
.on('album_user.role', '=', sql.lit(AlbumUserRole.Owner))
|
||||
.on('album_user.userId', '=', userId),
|
||||
)
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.execute()
|
||||
.then((albums) => new Set(albums.map((album) => album.id)));
|
||||
@@ -96,8 +106,8 @@ class AlbumAccess {
|
||||
return this.db
|
||||
.selectFrom('album')
|
||||
.select('album.id')
|
||||
.leftJoin('album_user', 'album_user.albumId', 'album.id')
|
||||
.leftJoin('user', (join) => join.onRef('user.id', '=', 'album_user.userId').on('user.deletedAt', 'is', null))
|
||||
.innerJoin('album_user', 'album_user.albumId', 'album.id')
|
||||
.innerJoin('user', (join) => join.onRef('user.id', '=', 'album_user.userId').on('user.deletedAt', 'is', null))
|
||||
.where('album.id', 'in', [...albumIds])
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.where('user.id', '=', userId)
|
||||
@@ -152,7 +162,7 @@ class AssetAccess {
|
||||
eb('asset.livePhotoVideoId', '=', sql<string>`any(target.ids)`),
|
||||
]),
|
||||
)
|
||||
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('user.id', '=', userId)]))
|
||||
.where('user.id', '=', userId)
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.execute()
|
||||
.then((assets) => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { AssetVisibility } from 'src/enum';
|
||||
import { DB } from 'src/schema';
|
||||
import { ActivityTable } from 'src/schema/tables/activity.table';
|
||||
import { asUuid } from 'src/utils/database';
|
||||
import { asUuid, dummy } from 'src/utils/database';
|
||||
|
||||
export interface ActivitySearch {
|
||||
albumId?: string;
|
||||
@@ -31,11 +31,7 @@ export class ActivityRepository {
|
||||
join.onRef('user2.id', '=', 'activity.userId').on('user2.deletedAt', 'is', null),
|
||||
)
|
||||
.innerJoinLateral(
|
||||
(eb) =>
|
||||
eb
|
||||
.selectFrom(sql`(select 1)`.as('dummy'))
|
||||
.select(columns.userWithPrefix)
|
||||
.as('user'),
|
||||
(eb) => eb.selectFrom(dummy).select(columns.userWithPrefix).as('user'),
|
||||
(join) => join.onTrue(),
|
||||
)
|
||||
.select((eb) => eb.fn.toJson('user').as('user'))
|
||||
|
||||
@@ -14,10 +14,11 @@ import { InjectKysely } from 'nestjs-kysely';
|
||||
import { columns } from 'src/database';
|
||||
import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { AlbumUserCreateDto } from 'src/dtos/album.dto';
|
||||
import { AlbumUserRole } from 'src/enum';
|
||||
import { DB } from 'src/schema';
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { withDefaultVisibility } from 'src/utils/database';
|
||||
import { asUuid, dummy, withDefaultVisibility } from 'src/utils/database';
|
||||
|
||||
export interface AlbumAssetCount {
|
||||
albumId: string;
|
||||
@@ -31,33 +32,25 @@ export interface AlbumInfoOptions {
|
||||
withAssets: boolean;
|
||||
}
|
||||
|
||||
const withOwner = (eb: ExpressionBuilder<DB, 'album'>) => {
|
||||
return jsonObjectFrom(eb.selectFrom('user').select(columns.user).whereRef('user.id', '=', 'album.ownerId'))
|
||||
.$notNull()
|
||||
.as('owner');
|
||||
};
|
||||
|
||||
const withAlbumUsers = (eb: ExpressionBuilder<DB, 'album'>) => {
|
||||
return jsonArrayFrom(
|
||||
const withAlbumUsers = (authUserId?: string) => (eb: ExpressionBuilder<DB, 'album'>) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('album_user')
|
||||
.innerJoin('user', 'user.id', 'album_user.userId')
|
||||
.whereRef('album_user.albumId', '=', 'album.id')
|
||||
.select('album_user.role')
|
||||
.select((eb) =>
|
||||
jsonObjectFrom(eb.selectFrom('user').select(columns.user).whereRef('user.id', '=', 'album_user.userId'))
|
||||
.$notNull()
|
||||
.as('user'),
|
||||
)
|
||||
.whereRef('album_user.albumId', '=', 'album.id'),
|
||||
.select((eb) => jsonObjectFrom(eb.selectFrom(dummy).select(columns.user)).$notNull().as('user'))
|
||||
.orderBy('album_user.role')
|
||||
.$if(!!authUserId, (qb) => qb.orderBy((eb) => eb('album_user.userId', '=', authUserId!), 'desc'))
|
||||
.orderBy('user.name', 'asc'),
|
||||
)
|
||||
.$notNull()
|
||||
.as('albumUsers');
|
||||
};
|
||||
|
||||
const withSharedLink = (eb: ExpressionBuilder<DB, 'album'>) => {
|
||||
return jsonArrayFrom(
|
||||
const withSharedLink = (eb: ExpressionBuilder<DB, 'album'>) =>
|
||||
jsonArrayFrom(
|
||||
eb.selectFrom('shared_link').selectAll('shared_link').whereRef('shared_link.albumId', '=', 'album.id'),
|
||||
).as('sharedLinks');
|
||||
};
|
||||
|
||||
const withAssets = (eb: ExpressionBuilder<DB, 'album'>) => {
|
||||
return eb
|
||||
@@ -80,19 +73,28 @@ const withAssets = (eb: ExpressionBuilder<DB, 'album'>) => {
|
||||
.as('assets');
|
||||
};
|
||||
|
||||
const isAlbumOwned = (ownerId: string) => (eb: ExpressionBuilder<DB, 'album'>) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('album_user')
|
||||
.whereRef('album_user.albumId', '=', 'album.id')
|
||||
.where('album_user.role', '=', AlbumUserRole.Owner)
|
||||
.where('album_user.userId', '=', ownerId),
|
||||
);
|
||||
|
||||
@Injectable()
|
||||
export class AlbumRepository {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, { withAssets: true }] })
|
||||
async getById(id: string, options: AlbumInfoOptions) {
|
||||
@GenerateSql({ params: [DummyValue.UUID, { withAssets: true }, DummyValue.UUID] })
|
||||
getById(id: string, options: AlbumInfoOptions, authUserId?: string) {
|
||||
return this.db
|
||||
.with('album_user', (qb) => qb.selectFrom('album_user').selectAll().where('album_user.albumId', '=', id))
|
||||
.selectFrom('album')
|
||||
.selectAll('album')
|
||||
.where('album.id', '=', id)
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.select(withOwner)
|
||||
.select(withAlbumUsers)
|
||||
.select(withAlbumUsers(authUserId))
|
||||
.select(withSharedLink)
|
||||
.$if(options.withAssets, (eb) => eb.select(withAssets))
|
||||
.$narrowType<{ assets: NotNull }>()
|
||||
@@ -100,27 +102,22 @@ export class AlbumRepository {
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
|
||||
async getByAssetId(ownerId: string, assetId: string) {
|
||||
getByAssetId(ownerId: string, assetId: string) {
|
||||
return this.db
|
||||
.selectFrom('album')
|
||||
.selectAll('album')
|
||||
.innerJoin('album_asset', 'album_asset.albumId', 'album.id')
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('album.ownerId', '=', ownerId),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('album_user')
|
||||
.whereRef('album_user.albumId', '=', 'album.id')
|
||||
.where('album_user.userId', '=', ownerId),
|
||||
),
|
||||
]),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('album_user')
|
||||
.whereRef('album_user.albumId', '=', 'album.id')
|
||||
.where('album_user.userId', '=', ownerId),
|
||||
),
|
||||
)
|
||||
.where('album_asset.assetId', '=', assetId)
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.orderBy('album.createdAt', 'desc')
|
||||
.select(withOwner)
|
||||
.select(withAlbumUsers)
|
||||
.select(withAlbumUsers(ownerId))
|
||||
.orderBy('album.createdAt', 'desc')
|
||||
.execute();
|
||||
}
|
||||
@@ -137,15 +134,12 @@ export class AlbumRepository {
|
||||
.select('album.id')
|
||||
.innerJoin('album_asset', 'album_asset.albumId', 'album.id')
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('album.ownerId', '=', ownerId),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('album_user')
|
||||
.whereRef('album_user.albumId', '=', 'album.id')
|
||||
.where('album_user.userId', '=', ownerId),
|
||||
),
|
||||
]),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('album_user')
|
||||
.whereRef('album_user.albumId', '=', 'album.id')
|
||||
.where('album_user.userId', '=', ownerId),
|
||||
),
|
||||
)
|
||||
.where('album_asset.assetId', 'in', assetIds)
|
||||
.where('album.deletedAt', 'is', null)
|
||||
@@ -190,15 +184,19 @@ export class AlbumRepository {
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getOwned(ownerId: string) {
|
||||
getOwned(ownerId: string) {
|
||||
return this.db
|
||||
.selectFrom('album')
|
||||
.selectAll('album')
|
||||
.select(withOwner)
|
||||
.select(withAlbumUsers)
|
||||
.select(withSharedLink)
|
||||
.where('album.ownerId', '=', ownerId)
|
||||
.innerJoin('album_user', (join) =>
|
||||
join
|
||||
.onRef('album_user.albumId', '=', 'album.id')
|
||||
.on('album_user.userId', '=', ownerId)
|
||||
.on('album_user.role', '=', sql.lit(AlbumUserRole.Owner)),
|
||||
)
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.select(withAlbumUsers(ownerId))
|
||||
.select(withSharedLink)
|
||||
.orderBy('album.createdAt', 'desc')
|
||||
.execute();
|
||||
}
|
||||
@@ -207,29 +205,40 @@ export class AlbumRepository {
|
||||
* Get albums shared with and shared by owner.
|
||||
*/
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getShared(ownerId: string) {
|
||||
getShared(ownerId: string) {
|
||||
return this.db
|
||||
.selectFrom('album')
|
||||
.selectAll('album')
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('album_user')
|
||||
.whereRef('album_user.albumId', '=', 'album.id')
|
||||
.where((eb) => eb.or([eb('album.ownerId', '=', ownerId), eb('album_user.userId', '=', ownerId)])),
|
||||
),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('shared_link')
|
||||
.whereRef('shared_link.albumId', '=', 'album.id')
|
||||
.where('shared_link.userId', '=', ownerId),
|
||||
),
|
||||
]),
|
||||
.innerJoin(
|
||||
(eb) =>
|
||||
eb
|
||||
.selectFrom('album_user')
|
||||
.select('album_user.albumId as id')
|
||||
.where('album_user.userId', '=', ownerId)
|
||||
.where(
|
||||
'album_user.albumId',
|
||||
'in',
|
||||
eb
|
||||
.selectFrom('album_user')
|
||||
.select('album_user.albumId')
|
||||
.where('album_user.role', '!=', sql.lit(AlbumUserRole.Owner)),
|
||||
)
|
||||
.union(
|
||||
eb
|
||||
.selectFrom('shared_link')
|
||||
.where('shared_link.userId', '=', ownerId)
|
||||
.where('shared_link.albumId', 'is not', null)
|
||||
.select('shared_link.albumId as id')
|
||||
.$narrowType<{ id: NotNull }>(),
|
||||
)
|
||||
.as('matching'),
|
||||
(join) => join.onRef('matching.id', '=', 'album.id'),
|
||||
)
|
||||
.innerJoin('album_user', (join) =>
|
||||
join.onRef('album_user.albumId', '=', 'album.id').on('album_user.role', '=', sql.lit(AlbumUserRole.Owner)),
|
||||
)
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.select(withAlbumUsers)
|
||||
.select(withOwner)
|
||||
.select(withAlbumUsers(ownerId))
|
||||
.select(withSharedLink)
|
||||
.orderBy('album.createdAt', 'desc')
|
||||
.execute();
|
||||
@@ -239,29 +248,45 @@ export class AlbumRepository {
|
||||
* Get albums of owner that are _not_ shared
|
||||
*/
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getNotShared(ownerId: string) {
|
||||
getNotShared(ownerId: string) {
|
||||
return this.db
|
||||
.selectFrom('album')
|
||||
.selectAll('album')
|
||||
.where('album.ownerId', '=', ownerId)
|
||||
.innerJoin('album_user', (join) =>
|
||||
join
|
||||
.onRef('album_user.albumId', '=', 'album.id')
|
||||
.on('album_user.userId', '=', ownerId)
|
||||
.on('album_user.role', '=', sql.lit(AlbumUserRole.Owner)),
|
||||
)
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.where((eb) => eb.not(eb.exists(eb.selectFrom('album_user').whereRef('album_user.albumId', '=', 'album.id'))))
|
||||
.where((eb) => eb.not(eb.exists(eb.selectFrom('shared_link').whereRef('shared_link.albumId', '=', 'album.id'))))
|
||||
.select(withOwner)
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
exists(
|
||||
selectFrom('album_user as au')
|
||||
.whereRef('au.albumId', '=', 'album.id')
|
||||
.where('au.role', '!=', sql.lit(AlbumUserRole.Owner)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(exists(selectFrom('shared_link').whereRef('shared_link.albumId', '=', 'album.id'))),
|
||||
)
|
||||
.select(withSharedLink)
|
||||
.select(withAlbumUsers(ownerId))
|
||||
.orderBy('album.createdAt', 'desc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
async restoreAll(userId: string): Promise<void> {
|
||||
await this.db.updateTable('album').set({ deletedAt: null }).where('ownerId', '=', userId).execute();
|
||||
await this.db.updateTable('album').set({ deletedAt: null }).where(isAlbumOwned(userId)).execute();
|
||||
}
|
||||
|
||||
async softDeleteAll(userId: string): Promise<void> {
|
||||
await this.db.updateTable('album').set({ deletedAt: new Date() }).where('ownerId', '=', userId).execute();
|
||||
await this.db.updateTable('album').set({ deletedAt: new Date() }).where(isAlbumOwned(userId)).execute();
|
||||
}
|
||||
|
||||
async deleteAll(userId: string): Promise<void> {
|
||||
await this.db.deleteFrom('album').where('ownerId', '=', userId).execute();
|
||||
await this.db.deleteFrom('album').where(isAlbumOwned(userId)).execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID]] })
|
||||
@@ -306,52 +331,86 @@ export class AlbumRepository {
|
||||
.then((results) => new Set(results.map(({ assetId }) => assetId)));
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
|
||||
async addAssetIds(albumId: string, assetIds: string[]): Promise<void> {
|
||||
await this.addAssets(this.db, albumId, assetIds);
|
||||
if (assetIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.db
|
||||
.insertInto('album_asset')
|
||||
.expression((eb) =>
|
||||
eb.selectFrom(dummy).select([asUuid(albumId).as('albumId'), sql`unnest(${assetIds}::uuid[])`.as('assetId')]),
|
||||
)
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
create(album: Insertable<AlbumTable>, assetIds: string[], albumUsers: AlbumUserCreateDto[]) {
|
||||
return this.db.transaction().execute(async (tx) => {
|
||||
const newAlbum = await tx.insertInto('album').values(album).returning('album.id').executeTakeFirst();
|
||||
@GenerateSql({
|
||||
params: [
|
||||
{ albumName: DummyValue.STRING },
|
||||
[],
|
||||
[{ userId: DummyValue.UUID, role: AlbumUserRole.Owner }, DummyValue.UUID],
|
||||
],
|
||||
})
|
||||
async create(
|
||||
album: Insertable<AlbumTable>,
|
||||
assetIds: string[],
|
||||
albumUsers: AlbumUserCreateDto[],
|
||||
authUserId: string,
|
||||
) {
|
||||
if (!albumUsers.some((u) => u.role === AlbumUserRole.Owner)) {
|
||||
throw new Error('Album must have an owner');
|
||||
}
|
||||
|
||||
if (!newAlbum) {
|
||||
throw new Error('Failed to create album');
|
||||
}
|
||||
const userIds = albumUsers.map((u) => u.userId);
|
||||
const roles = albumUsers.map((u) => u.role);
|
||||
|
||||
if (assetIds.length > 0) {
|
||||
await this.addAssets(tx, newAlbum.id, assetIds);
|
||||
}
|
||||
|
||||
if (albumUsers.length > 0) {
|
||||
await tx
|
||||
const result = await this.db
|
||||
.with('album', (db) => db.insertInto('album').values(album).returningAll())
|
||||
.with('album_user', (db) =>
|
||||
db
|
||||
.insertInto('album_user')
|
||||
.values(
|
||||
albumUsers.map((albumUser) => ({ albumId: newAlbum.id, userId: albumUser.userId, role: albumUser.role })),
|
||||
.expression((eb) =>
|
||||
eb
|
||||
.selectFrom('album')
|
||||
.select(({ ref }) => [
|
||||
ref('album.id').as('albumId'),
|
||||
sql`unnest(${userIds}::uuid[])`.as('userId'),
|
||||
sql`unnest(${roles}::album_user_role_enum[])`.as('role'),
|
||||
]),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
.returning(['album_user.albumId', 'album_user.userId', 'album_user.role']),
|
||||
)
|
||||
.with('album_asset', (db) =>
|
||||
db
|
||||
.insertInto('album_asset')
|
||||
.expression((eb) =>
|
||||
eb
|
||||
.selectFrom('album')
|
||||
.select(({ ref }) => [ref('album.id').as('albumId'), sql`unnest(${assetIds}::uuid[])`.as('assetId')]),
|
||||
)
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.returning(['album_asset.albumId', 'album_asset.assetId']),
|
||||
)
|
||||
.selectFrom('album')
|
||||
.selectAll('album')
|
||||
.select(withAlbumUsers(authUserId))
|
||||
.select(withAssets)
|
||||
.$narrowType<{ assets: NotNull }>()
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return tx
|
||||
.selectFrom('album')
|
||||
.selectAll('album')
|
||||
.where('id', '=', newAlbum.id)
|
||||
.select(withOwner)
|
||||
.select(withAssets)
|
||||
.select(withAlbumUsers)
|
||||
.$narrowType<{ assets: NotNull }>()
|
||||
.executeTakeFirstOrThrow();
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
update(id: string, album: Updateable<AlbumTable>) {
|
||||
update(id: string, album: Updateable<AlbumTable>, authUserId: string) {
|
||||
return this.db
|
||||
.updateTable('album')
|
||||
.set(album)
|
||||
.where('id', '=', id)
|
||||
.where('album.id', '=', id)
|
||||
.returningAll('album')
|
||||
.returning(withOwner)
|
||||
.returning(withSharedLink)
|
||||
.returning(withAlbumUsers)
|
||||
.returning(withAlbumUsers(authUserId))
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
@@ -359,19 +418,6 @@ export class AlbumRepository {
|
||||
await this.db.deleteFrom('album').where('id', '=', id).execute();
|
||||
}
|
||||
|
||||
@Chunked({ paramIndex: 2, chunkSize: 30_000 })
|
||||
private async addAssets(db: Kysely<DB>, albumId: string, assetIds: string[]): Promise<void> {
|
||||
if (assetIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.insertInto('album_asset')
|
||||
.values(assetIds.map((assetId) => ({ albumId, assetId })))
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
@Chunked({ chunkSize: 30_000 })
|
||||
async addAssetIdsToAlbums(values: { albumId: string; assetId: string }[]): Promise<void> {
|
||||
if (values.length === 0) {
|
||||
@@ -402,7 +448,7 @@ export class AlbumRepository {
|
||||
albumThumbnailAssetId: this.updateThumbnailBuilder(eb)
|
||||
.select('album_asset.assetId')
|
||||
.orderBy('asset.fileCreatedAt', 'desc')
|
||||
.limit(1),
|
||||
.limit(sql.lit(1)),
|
||||
}))
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
|
||||
@@ -106,19 +106,6 @@ interface AssetExploreFieldOptions {
|
||||
minAssetsPerField: number;
|
||||
}
|
||||
|
||||
interface AssetFullSyncOptions {
|
||||
ownerId: string;
|
||||
lastId?: string;
|
||||
updatedUntil: Date;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
interface AssetDeltaSyncOptions {
|
||||
userIds: string[];
|
||||
updatedAfter: Date;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
interface AssetGetByChecksumOptions {
|
||||
ownerId: string;
|
||||
checksum: Buffer;
|
||||
@@ -318,7 +305,7 @@ export class AssetRepository {
|
||||
.execute();
|
||||
}
|
||||
|
||||
upsertMetadata(id: string, items: Array<{ key: string; value: object }>) {
|
||||
upsertMetadata(id: string, items: Array<{ key: string; value: Record<string, unknown> }>) {
|
||||
if (items.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -461,18 +448,6 @@ export class AssetRepository {
|
||||
await this.db.deleteFrom('asset').where('ownerId', '=', ownerId).execute();
|
||||
}
|
||||
|
||||
async getByDeviceIds(ownerId: string, deviceId: string, deviceAssetIds: string[]): Promise<string[]> {
|
||||
const assets = await this.db
|
||||
.selectFrom('asset')
|
||||
.select(['deviceAssetId'])
|
||||
.where('deviceAssetId', 'in', deviceAssetIds)
|
||||
.where('deviceId', '=', deviceId)
|
||||
.where('ownerId', '=', asUuid(ownerId))
|
||||
.execute();
|
||||
|
||||
return assets.map((asset) => asset.deviceAssetId);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
|
||||
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string) {
|
||||
return this.db
|
||||
@@ -484,27 +459,6 @@ export class AssetRepository {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get assets by device's Id on the database
|
||||
* @param ownerId
|
||||
* @param deviceId
|
||||
*
|
||||
* @returns Promise<string[]> - Array of assetIds belong to the device
|
||||
*/
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
|
||||
async getAllByDeviceId(ownerId: string, deviceId: string): Promise<string[]> {
|
||||
const items = await this.db
|
||||
.selectFrom('asset')
|
||||
.select(['deviceAssetId'])
|
||||
.where('ownerId', '=', asUuid(ownerId))
|
||||
.where('deviceId', '=', deviceId)
|
||||
.where('visibility', '!=', AssetVisibility.Hidden)
|
||||
.where('deletedAt', 'is', null)
|
||||
.execute();
|
||||
|
||||
return items.map((asset) => asset.deviceAssetId);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getLivePhotoCount(motionId: string): Promise<number> {
|
||||
const [{ count }] = await this.db
|
||||
@@ -581,7 +535,7 @@ export class AssetRepository {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID], { deviceId: DummyValue.STRING }] })
|
||||
@GenerateSql({ params: [[DummyValue.UUID], {}] })
|
||||
@Chunked()
|
||||
async updateAll(ids: string[], options: Updateable<AssetTable>): Promise<void> {
|
||||
if (ids.length === 0) {
|
||||
@@ -681,19 +635,6 @@ export class AssetRepository {
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
getRandom(userIds: string[], take: number) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.selectAll('asset')
|
||||
.$call(withExif)
|
||||
.$call(withDefaultVisibility)
|
||||
.where('ownerId', '=', anyUuid(userIds))
|
||||
.where('deletedAt', 'is', null)
|
||||
.orderBy((eb) => eb.fn('random'))
|
||||
.limit(take)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{}] })
|
||||
async getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]> {
|
||||
return this.db
|
||||
@@ -918,70 +859,6 @@ export class AssetRepository {
|
||||
return { fieldName: 'exifInfo.city', items };
|
||||
}
|
||||
|
||||
@GenerateSql({
|
||||
params: [
|
||||
{
|
||||
ownerId: DummyValue.UUID,
|
||||
lastId: DummyValue.UUID,
|
||||
updatedUntil: DummyValue.DATE,
|
||||
limit: 10,
|
||||
},
|
||||
],
|
||||
})
|
||||
getAllForUserFullSync(options: AssetFullSyncOptions) {
|
||||
const { ownerId, lastId, updatedUntil, limit } = options;
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.selectAll('asset')
|
||||
.$call(withExif)
|
||||
.leftJoin('stack', 'stack.id', 'asset.stackId')
|
||||
.leftJoinLateral(
|
||||
(eb) =>
|
||||
eb
|
||||
.selectFrom('asset as stacked')
|
||||
.selectAll('stack')
|
||||
.select((eb) => eb.fn.count(eb.table('stacked')).as('assetCount'))
|
||||
.whereRef('stacked.stackId', '=', 'stack.id')
|
||||
.groupBy('stack.id')
|
||||
.as('stacked_assets'),
|
||||
(join) => join.on('stack.id', 'is not', null),
|
||||
)
|
||||
.select((eb) => eb.fn.toJson(eb.table('stacked_assets')).$castTo<Stack | null>().as('stack'))
|
||||
.where('asset.ownerId', '=', asUuid(ownerId))
|
||||
.where('asset.visibility', '!=', AssetVisibility.Hidden)
|
||||
.where('asset.updatedAt', '<=', updatedUntil)
|
||||
.$if(!!lastId, (qb) => qb.where('asset.id', '>', lastId!))
|
||||
.orderBy('asset.id')
|
||||
.limit(limit)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ userIds: [DummyValue.UUID], updatedAfter: DummyValue.DATE, limit: 100 }] })
|
||||
async getChangedDeltaSync(options: AssetDeltaSyncOptions) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.selectAll('asset')
|
||||
.$call(withExif)
|
||||
.leftJoin('stack', 'stack.id', 'asset.stackId')
|
||||
.leftJoinLateral(
|
||||
(eb) =>
|
||||
eb
|
||||
.selectFrom('asset as stacked')
|
||||
.selectAll('stack')
|
||||
.select((eb) => eb.fn.count(eb.table('stacked')).as('assetCount'))
|
||||
.whereRef('stacked.stackId', '=', 'stack.id')
|
||||
.groupBy('stack.id')
|
||||
.as('stacked_assets'),
|
||||
(join) => join.on('stack.id', 'is not', null),
|
||||
)
|
||||
.select((eb) => eb.fn.toJson(eb.table('stacked_assets').$castTo<Stack | null>()).as('stack'))
|
||||
.where('asset.ownerId', '=', anyUuid(options.userIds))
|
||||
.where('asset.visibility', '!=', AssetVisibility.Hidden)
|
||||
.where('asset.updatedAt', '>', options.updatedAfter)
|
||||
.limit(options.limit)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async upsertFile(
|
||||
file: Pick<
|
||||
Insertable<AssetFileTable>,
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Kysely } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { DatabaseAction, EntityType } from 'src/enum';
|
||||
import { DB } from 'src/schema';
|
||||
|
||||
export interface AuditSearch {
|
||||
action?: DatabaseAction;
|
||||
entityType?: EntityType;
|
||||
userIds: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuditRepository {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
@GenerateSql({
|
||||
params: [
|
||||
DummyValue.DATE,
|
||||
{ action: DatabaseAction.Create, entityType: EntityType.Asset, userIds: [DummyValue.UUID] },
|
||||
],
|
||||
})
|
||||
async getAfter(since: Date, options: AuditSearch): Promise<string[]> {
|
||||
const records = await this.db
|
||||
.selectFrom('audit')
|
||||
.where('audit.createdAt', '>', since)
|
||||
.$if(!!options.action, (qb) => qb.where('audit.action', '=', options.action!))
|
||||
.$if(!!options.entityType, (qb) => qb.where('audit.entityType', '=', options.entityType!))
|
||||
.where('audit.ownerId', 'in', options.userIds)
|
||||
.distinctOn(['audit.entityId', 'audit.entityType'])
|
||||
.orderBy('audit.entityId', 'desc')
|
||||
.orderBy('audit.entityType', 'desc')
|
||||
.orderBy('audit.createdAt', 'desc')
|
||||
.select('audit.entityId')
|
||||
.execute();
|
||||
|
||||
return records.map(({ entityId }) => entityId);
|
||||
}
|
||||
|
||||
async removeBefore(before: Date): Promise<void> {
|
||||
await this.db.deleteFrom('audit').where('createdAt', '<', before).execute();
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ describe('getEnv', () => {
|
||||
describe('IMMICH_MEDIA_LOCATION', () => {
|
||||
it('should throw an error for relative paths', () => {
|
||||
process.env.IMMICH_MEDIA_LOCATION = './relative/path';
|
||||
expect(() => getEnv()).toThrowError('IMMICH_MEDIA_LOCATION must be an absolute path');
|
||||
expect(() => getEnv()).toThrowError('[IMMICH_MEDIA_LOCATION] Must be an absolute path');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,7 +98,7 @@ describe('getEnv', () => {
|
||||
|
||||
it('should throw an error for invalid value', () => {
|
||||
process.env.IMMICH_ALLOW_EXTERNAL_PLUGINS = 'invalid';
|
||||
expect(() => getEnv()).toThrowError('IMMICH_ALLOW_EXTERNAL_PLUGINS must be a boolean value');
|
||||
expect(() => getEnv()).toThrowError('[IMMICH_ALLOW_EXTERNAL_PLUGINS] Invalid option: expected one of');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('getEnv', () => {
|
||||
|
||||
it('should throw an error for invalid value', () => {
|
||||
process.env.IMMICH_ALLOW_SETUP = 'invalid';
|
||||
expect(() => getEnv()).toThrowError('IMMICH_ALLOW_SETUP must be a boolean value');
|
||||
expect(() => getEnv()).toThrowError('[IMMICH_ALLOW_SETUP] Invalid option: expected one of');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -134,7 +134,7 @@ describe('getEnv', () => {
|
||||
|
||||
it('should validate DB_SSL_MODE', () => {
|
||||
process.env.DB_SSL_MODE = 'invalid';
|
||||
expect(() => getEnv()).toThrowError('DB_SSL_MODE must be one of the following values:');
|
||||
expect(() => getEnv()).toThrow(/\[DB_SSL_MODE\] Invalid option: expected one of/);
|
||||
});
|
||||
|
||||
it('should accept a valid DB_SSL_MODE', () => {
|
||||
@@ -278,7 +278,7 @@ describe('getEnv', () => {
|
||||
|
||||
it('should reject invalid trusted proxies', () => {
|
||||
process.env.IMMICH_TRUSTED_PROXIES = '10.1';
|
||||
expect(() => getEnv()).toThrow('IMMICH_TRUSTED_PROXIES must be an ip address, or ip address range');
|
||||
expect(() => getEnv()).toThrow('[IMMICH_TRUSTED_PROXIES] Must be an ip address or ip address range');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ import { DatabaseConnectionParams } from '@immich/sql-tools';
|
||||
import { RegisterQueueOptions } from '@nestjs/bullmq';
|
||||
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||
import { QueueOptions } from 'bullmq';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validateSync } from 'class-validator';
|
||||
import { Request, Response } from 'express';
|
||||
import { HelmetOptions } from 'helmet';
|
||||
import { RedisOptions } from 'ioredis';
|
||||
@@ -13,7 +11,7 @@ import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { citiesFile, excludePaths, IWorker } from 'src/constants';
|
||||
import { Telemetry } from 'src/decorators';
|
||||
import { EnvDto } from 'src/dtos/env.dto';
|
||||
import { EnvSchema } from 'src/dtos/env.dto';
|
||||
import {
|
||||
DatabaseExtension,
|
||||
ImmichEnvironment,
|
||||
@@ -173,15 +171,16 @@ const resolveHelmetFile = (helmetFile: 'true' | 'false' | string | undefined) =>
|
||||
};
|
||||
|
||||
const getEnv = (): EnvData => {
|
||||
const dto = plainToInstance(EnvDto, process.env);
|
||||
const errors = validateSync(dto);
|
||||
if (errors.length > 0) {
|
||||
const messages = [`Invalid environment variables: `];
|
||||
for (const error of errors) {
|
||||
messages.push(` - ${error.property}=${error.value} (${Object.values(error.constraints || {}).join(', ')})`);
|
||||
const parseResult = EnvSchema.safeParse(process.env);
|
||||
if (!parseResult.success) {
|
||||
const messages = ['Invalid environment variables: '];
|
||||
for (const issue of parseResult.error.issues) {
|
||||
const path = issue.path.join('.');
|
||||
messages.push(` - [${path}] ${issue.message}`);
|
||||
}
|
||||
throw new Error(messages.join('\n'));
|
||||
}
|
||||
const dto = parseResult.data;
|
||||
|
||||
const includedWorkers = asSet(dto.IMMICH_WORKERS_INCLUDE, [ImmichWorker.Api, ImmichWorker.Microservices]);
|
||||
const excludedWorkers = asSet(dto.IMMICH_WORKERS_EXCLUDE, []);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ModuleRef, Reflector } from '@nestjs/core';
|
||||
import { ClassConstructor } from 'class-transformer';
|
||||
import _ from 'lodash';
|
||||
import { Socket } from 'socket.io';
|
||||
import { SystemConfig } from 'src/config';
|
||||
@@ -40,7 +39,7 @@ type EventMap = {
|
||||
|
||||
// album events
|
||||
AlbumUpdate: [{ id: string; recipientId: string }];
|
||||
AlbumInvite: [{ id: string; userId: string }];
|
||||
AlbumInvite: [{ id: string; userId: string; senderName: string }];
|
||||
|
||||
// asset events
|
||||
AssetCreate: [{ asset: Asset }];
|
||||
@@ -152,7 +151,7 @@ export class EventRepository {
|
||||
this.logger.setContext(EventRepository.name);
|
||||
}
|
||||
|
||||
setup({ services }: { services: ClassConstructor<unknown>[] }) {
|
||||
setup({ services }: { services: (new (...args: any[]) => unknown)[] }) {
|
||||
const reflector = this.moduleRef.get(Reflector, { strict: false });
|
||||
const items: Item<EmitEvent>[] = [];
|
||||
const worker = this.configRepository.getWorker();
|
||||
|
||||
@@ -7,7 +7,6 @@ import { AppRepository } from 'src/repositories/app.repository';
|
||||
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
|
||||
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { AuditRepository } from 'src/repositories/audit.repository';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { CronRepository } from 'src/repositories/cron.repository';
|
||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||
@@ -56,7 +55,6 @@ export const repositories = [
|
||||
ActivityRepository,
|
||||
AlbumRepository,
|
||||
AlbumUserRepository,
|
||||
AuditRepository,
|
||||
ApiKeyRepository,
|
||||
AppRepository,
|
||||
AssetRepository,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { getQueueToken } from '@nestjs/bullmq';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ModuleRef, Reflector } from '@nestjs/core';
|
||||
import { JobsOptions, Queue, Worker } from 'bullmq';
|
||||
import { ClassConstructor } from 'class-transformer';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { JobConfig } from 'src/decorators';
|
||||
import { QueueJobResponseDto, QueueJobSearchDto } from 'src/dtos/queue.dto';
|
||||
@@ -34,7 +33,7 @@ export class JobRepository {
|
||||
this.logger.setContext(JobRepository.name);
|
||||
}
|
||||
|
||||
setup(services: ClassConstructor<unknown>[]) {
|
||||
setup(services: (new (...args: any[]) => unknown)[]) {
|
||||
const reflector = this.moduleRef.get(Reflector, { strict: false });
|
||||
|
||||
// discovery
|
||||
|
||||
@@ -33,12 +33,6 @@ export interface ReverseGeocodeResult {
|
||||
city: string | null;
|
||||
}
|
||||
|
||||
export interface MapMarker extends ReverseGeocodeResult {
|
||||
id: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
interface MapDB extends DB {
|
||||
geodata_places_tmp: GeodataPlacesTable;
|
||||
naturalearth_countries_tmp: NaturalEarthCountriesTable;
|
||||
@@ -76,29 +70,21 @@ export class MapRepository {
|
||||
this.logger.log('Geodata import completed');
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getAlbumMapMarkers(albumId: string) {
|
||||
return this.mapMarkersQuery()
|
||||
.innerJoin('album_asset', 'asset.id', 'album_asset.assetId')
|
||||
.where('album_asset.albumId', '=', albumId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID], [DummyValue.UUID]] })
|
||||
getMapMarkers(
|
||||
ownerIds: string[],
|
||||
albumIds: string[],
|
||||
{ isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore }: MapMarkerSearchOptions = {},
|
||||
) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.innerJoin('asset_exif', (builder) =>
|
||||
builder
|
||||
.onRef('asset.id', '=', 'asset_exif.assetId')
|
||||
.on('asset_exif.latitude', 'is not', null)
|
||||
.on('asset_exif.longitude', 'is not', null),
|
||||
)
|
||||
.select([
|
||||
'id',
|
||||
'asset_exif.latitude as lat',
|
||||
'asset_exif.longitude as lon',
|
||||
'asset_exif.city',
|
||||
'asset_exif.state',
|
||||
'asset_exif.country',
|
||||
])
|
||||
.$narrowType<{ lat: NotNull; lon: NotNull }>()
|
||||
return this.mapMarkersQuery()
|
||||
.$if(isArchived === true, (qb) =>
|
||||
qb.where((eb) =>
|
||||
eb.or([
|
||||
@@ -113,7 +99,6 @@ export class MapRepository {
|
||||
.$if(isFavorite !== undefined, (q) => q.where('isFavorite', '=', isFavorite!))
|
||||
.$if(fileCreatedAfter !== undefined, (q) => q.where('fileCreatedAt', '>=', fileCreatedAfter!))
|
||||
.$if(fileCreatedBefore !== undefined, (q) => q.where('fileCreatedAt', '<=', fileCreatedBefore!))
|
||||
.where('deletedAt', 'is', null)
|
||||
.where((eb) => {
|
||||
const expression: Expression<SqlBool>[] = [];
|
||||
|
||||
@@ -134,10 +119,31 @@ export class MapRepository {
|
||||
|
||||
return eb.or(expression);
|
||||
})
|
||||
.orderBy('fileCreatedAt', 'desc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
private mapMarkersQuery() {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.innerJoin('asset_exif', (builder) =>
|
||||
builder
|
||||
.onRef('asset.id', '=', 'asset_exif.assetId')
|
||||
.on('asset_exif.latitude', 'is not', null)
|
||||
.on('asset_exif.longitude', 'is not', null),
|
||||
)
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.orderBy('fileCreatedAt', 'desc')
|
||||
.select([
|
||||
'id',
|
||||
'asset_exif.latitude as lat',
|
||||
'asset_exif.longitude as lon',
|
||||
'asset_exif.city',
|
||||
'asset_exif.state',
|
||||
'asset_exif.country',
|
||||
])
|
||||
.$narrowType<{ lat: NotNull; lon: NotNull }>();
|
||||
}
|
||||
|
||||
async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult> {
|
||||
this.logger.debug(`Request: ${point.latitude},${point.longitude}`);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { createRemoteJWKSet, jwtVerify, JWTVerifyGetKey } from 'jose';
|
||||
import {
|
||||
allowInsecureRequests,
|
||||
allowInsecureRequests as allowInsecureRequestsExecute,
|
||||
authorizationCodeGrant,
|
||||
buildAuthorizationUrl,
|
||||
calculatePKCECodeChallenge,
|
||||
@@ -21,13 +22,16 @@ export type OAuthConfig = {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
issuerUrl: string;
|
||||
endSessionEndpoint: string;
|
||||
mobileOverrideEnabled: boolean;
|
||||
mobileRedirectUri: string;
|
||||
profileSigningAlgorithm: string;
|
||||
prompt: string;
|
||||
scope: string;
|
||||
signingAlgorithm: string;
|
||||
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod;
|
||||
timeout: number;
|
||||
allowInsecureRequests: boolean;
|
||||
};
|
||||
export type OAuthProfile = UserInfoResponse;
|
||||
|
||||
@@ -55,6 +59,10 @@ export class OAuthRepository {
|
||||
state,
|
||||
};
|
||||
|
||||
if (config.prompt) {
|
||||
params.prompt = config.prompt;
|
||||
}
|
||||
|
||||
if (client.serverMetadata().supportsPKCE()) {
|
||||
params.code_challenge = codeChallenge;
|
||||
params.code_challenge_method = 'S256';
|
||||
@@ -70,12 +78,12 @@ export class OAuthRepository {
|
||||
return client.serverMetadata().end_session_endpoint;
|
||||
}
|
||||
|
||||
async getProfile(
|
||||
async getProfileAndOAuthSid(
|
||||
config: OAuthConfig,
|
||||
url: string,
|
||||
expectedState: string,
|
||||
codeVerifier: string,
|
||||
): Promise<OAuthProfile> {
|
||||
): Promise<{ profile: OAuthProfile; sid?: string }> {
|
||||
const client = await this.getClient(config);
|
||||
const pkceCodeVerifier = client.serverMetadata().supportsPKCE() ? codeVerifier : undefined;
|
||||
|
||||
@@ -95,7 +103,15 @@ export class OAuthRepository {
|
||||
throw new Error('Unexpected profile response, no `sub`');
|
||||
}
|
||||
|
||||
return profile;
|
||||
let sid: string | undefined;
|
||||
if (tokens.id_token) {
|
||||
const claims = tokens.claims();
|
||||
if (typeof claims?.sid === 'string') {
|
||||
sid = claims.sid;
|
||||
}
|
||||
}
|
||||
|
||||
return { profile, sid };
|
||||
} catch (error: Error | any) {
|
||||
if (error.message.includes('unexpected JWT alg received')) {
|
||||
this.logger.warn(
|
||||
@@ -125,6 +141,59 @@ export class OAuthRepository {
|
||||
};
|
||||
}
|
||||
|
||||
private jwksClients: Map<string, JWTVerifyGetKey> = new Map(); // useful for caching and performnce
|
||||
async validateLogoutToken(config: OAuthConfig, logoutToken: string): Promise<{ sub?: string; sid?: string } | null> {
|
||||
const client = await this.getClient(config);
|
||||
const algorithm = client.clientMetadata().id_token_signed_response_alg ?? 'RS256';
|
||||
let keyOrGetter: Uint8Array | JWTVerifyGetKey;
|
||||
|
||||
try {
|
||||
if (algorithm.startsWith('HS')) {
|
||||
keyOrGetter = new TextEncoder().encode(config.clientSecret);
|
||||
} else {
|
||||
const jwksUri = client.serverMetadata().jwks_uri;
|
||||
if (!jwksUri) {
|
||||
throw new Error('Unable to get JWKS URI');
|
||||
}
|
||||
|
||||
if (!this.jwksClients.has(jwksUri)) {
|
||||
this.jwksClients.set(jwksUri, createRemoteJWKSet(new URL(jwksUri)));
|
||||
}
|
||||
keyOrGetter = this.jwksClients.get(jwksUri) as JWTVerifyGetKey;
|
||||
}
|
||||
|
||||
const { payload } = await jwtVerify(logoutToken, keyOrGetter as any, {
|
||||
issuer: client.serverMetadata().issuer,
|
||||
audience: config.clientId,
|
||||
algorithms: [algorithm],
|
||||
maxTokenAge: '2m',
|
||||
clockTolerance: '5s',
|
||||
});
|
||||
|
||||
// Validate specific Logout Token claims (RFC 8963):
|
||||
// "events" claim must exist and contain the backchannel-logout event
|
||||
const events = payload.events as Record<string, any> | undefined;
|
||||
if (!events || !events['http://schemas.openid.net/event/backchannel-logout']) {
|
||||
throw new Error('Missing backchannel-logout event claim');
|
||||
}
|
||||
|
||||
// "nonce" must not be present
|
||||
if (payload.nonce) {
|
||||
throw new Error('Logout token must not contain a nonce');
|
||||
}
|
||||
|
||||
return {
|
||||
sub: payload.sub,
|
||||
sid: payload.sid as string | undefined,
|
||||
};
|
||||
} catch (error: Error | any) {
|
||||
this.logger.error(`Error validating JWT logout token: ${error.message}`);
|
||||
this.logger.error(error);
|
||||
|
||||
throw new Error('Error validating JWT logout token', { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
private async getClient({
|
||||
issuerUrl,
|
||||
clientId,
|
||||
@@ -133,6 +202,7 @@ export class OAuthRepository {
|
||||
signingAlgorithm,
|
||||
tokenEndpointAuthMethod,
|
||||
timeout,
|
||||
allowInsecureRequests,
|
||||
}: OAuthConfig) {
|
||||
try {
|
||||
return await discovery(
|
||||
@@ -146,7 +216,7 @@ export class OAuthRepository {
|
||||
},
|
||||
this.getTokenAuthMethod(tokenEndpointAuthMethod, clientSecret),
|
||||
{
|
||||
execute: [allowInsecureRequests],
|
||||
execute: allowInsecureRequests ? [allowInsecureRequestsExecute] : [],
|
||||
timeout,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { DB } from 'src/schema';
|
||||
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
|
||||
import { PersonTable } from 'src/schema/tables/person.table';
|
||||
import { removeUndefinedKeys, withFilePath } from 'src/utils/database';
|
||||
import { dummy, removeUndefinedKeys, withFilePath } from 'src/utils/database';
|
||||
import { paginationHelper, PaginationOptions } from 'src/utils/pagination';
|
||||
|
||||
export interface PersonSearchOptions {
|
||||
@@ -418,7 +418,7 @@ export class PersonRepository {
|
||||
(query as any) = query.with('added_embeddings', (db) => db.insertInto('face_search').values(embeddingsToAdd));
|
||||
}
|
||||
|
||||
await query.selectFrom(sql`(select 1)`.as('dummy')).execute();
|
||||
await query.selectFrom(dummy).execute();
|
||||
}
|
||||
|
||||
async update(person: Updateable<PersonTable> & { id: string }) {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Kysely, OrderByDirection, Selectable, ShallowDehydrateObject, sql } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetStatus, AssetType, AssetVisibility, VectorIndex } from 'src/enum';
|
||||
import { probes } from 'src/repositories/database.repository';
|
||||
import { DB } from 'src/schema';
|
||||
@@ -14,12 +12,10 @@ import { isValidInteger } from 'src/validation';
|
||||
|
||||
export interface SearchAssetIdOptions {
|
||||
checksum?: Buffer;
|
||||
deviceAssetId?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface SearchUserIdOptions {
|
||||
deviceId?: string;
|
||||
libraryId?: string | null;
|
||||
userIds?: string[];
|
||||
}
|
||||
@@ -238,20 +234,11 @@ export class SearchRepository {
|
||||
],
|
||||
})
|
||||
async searchRandom(size: number, options: AssetSearchOptions) {
|
||||
const uuid = randomUUID();
|
||||
const builder = searchAssetBuilder(this.db, options);
|
||||
const lessThan = builder
|
||||
return searchAssetBuilder(this.db, options)
|
||||
.selectAll('asset')
|
||||
.where('asset.id', '<', uuid)
|
||||
.orderBy(sql`random()`)
|
||||
.limit(size);
|
||||
const greaterThan = builder
|
||||
.selectAll('asset')
|
||||
.where('asset.id', '>', uuid)
|
||||
.orderBy(sql`random()`)
|
||||
.limit(size);
|
||||
const { rows } = await sql<MapAsset>`${lessThan} union all ${greaterThan} limit ${size}`.execute(this.db);
|
||||
return rows;
|
||||
.limit(size)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({
|
||||
|
||||
@@ -102,7 +102,7 @@ export class SessionRepository {
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ userId: DummyValue.UUID, excludeId: DummyValue.UUID }] })
|
||||
async invalidate({ userId, excludeId }: { userId: string; excludeId?: string }) {
|
||||
async invalidateAll({ userId, excludeId }: { userId: string; excludeId?: string }) {
|
||||
await this.db
|
||||
.deleteFrom('session')
|
||||
.where('userId', '=', userId)
|
||||
@@ -110,6 +110,28 @@ export class SessionRepository {
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.STRING, DummyValue.STRING] })
|
||||
async invalidateOAuth({ oauthSid, oauthId }: { oauthSid?: string; oauthId?: string }): Promise<string[]> {
|
||||
let query = this.db.deleteFrom('session').returning('session.id');
|
||||
|
||||
if (oauthSid && oauthId) {
|
||||
query = query
|
||||
.using('user')
|
||||
.whereRef('user.id', '=', 'session.userId')
|
||||
.where('session.oauthSid', '=', oauthSid)
|
||||
.where('user.oauthId', '=', oauthId);
|
||||
} else if (!oauthSid && oauthId) {
|
||||
query = query.using('user').whereRef('user.id', '=', 'session.userId').where('user.oauthId', '=', oauthId);
|
||||
} else if (oauthSid && !oauthId) {
|
||||
query = query.where('session.oauthSid', '=', oauthSid);
|
||||
} else {
|
||||
throw new Error('Invalid arguments: at least one of oauthSid or oauthId must be present');
|
||||
}
|
||||
|
||||
const deletedRows = await query.execute();
|
||||
return deletedRows.map((row) => row.id);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async lockAll(userId: string) {
|
||||
await this.db.updateTable('session').set({ pinExpiresAt: null }).where('userId', '=', userId).execute();
|
||||
|
||||
@@ -5,7 +5,7 @@ import _ from 'lodash';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { Album, columns } from 'src/database';
|
||||
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { SharedLinkType } from 'src/enum';
|
||||
import { AlbumUserRole, SharedLinkType } from 'src/enum';
|
||||
import { DB } from 'src/schema';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
@@ -39,7 +39,15 @@ const withAlbumOwner = (eb: ExpressionBuilder<DB, 'album'>) => {
|
||||
return eb
|
||||
.selectFrom('user')
|
||||
.select(columns.user)
|
||||
.whereRef('user.id', '=', 'album.ownerId')
|
||||
.where((eb) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('album_user')
|
||||
.where('album_user.role', '=', sql.lit(AlbumUserRole.Owner))
|
||||
.whereRef('album_user.albumId', '=', 'album.id')
|
||||
.whereRef('album_user.userId', '=', 'user.id'),
|
||||
),
|
||||
)
|
||||
.where('user.deletedAt', 'is', null)
|
||||
.as('owner');
|
||||
};
|
||||
|
||||
@@ -173,10 +173,9 @@ class AlbumSync extends BaseSync {
|
||||
return this.upsertQuery('album', options)
|
||||
.distinctOn(['album.id', 'album.updateId'])
|
||||
.leftJoin('album_user as album_users', 'album.id', 'album_users.albumId')
|
||||
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_users.userId', '=', userId)]))
|
||||
.where('album_users.userId', '=', userId)
|
||||
.select([
|
||||
'album.id',
|
||||
'album.ownerId',
|
||||
'album.albumName as name',
|
||||
'album.description',
|
||||
'album.createdAt',
|
||||
@@ -188,6 +187,11 @@ class AlbumSync extends BaseSync {
|
||||
])
|
||||
.stream();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getAlbumUsers(albumId: string) {
|
||||
return this.db.selectFrom('album_user').select(['userId', 'role']).where('albumId', '=', albumId).execute();
|
||||
}
|
||||
}
|
||||
|
||||
class AlbumAssetSync extends BaseSync {
|
||||
@@ -209,9 +213,8 @@ class AlbumAssetSync extends BaseSync {
|
||||
.select(columns.syncAsset)
|
||||
.select('asset.updateId')
|
||||
.where('album_asset.updateId', '<=', albumToAssetAck.updateId) // Ensure we only send updates for assets that the client already knows about
|
||||
.innerJoin('album', 'album.id', 'album_asset.albumId')
|
||||
.leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
|
||||
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)]))
|
||||
.innerJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
|
||||
.where('album_user.userId', '=', userId)
|
||||
.stream();
|
||||
}
|
||||
|
||||
@@ -222,9 +225,8 @@ class AlbumAssetSync extends BaseSync {
|
||||
.select('album_asset.updateId')
|
||||
.innerJoin('asset', 'asset.id', 'album_asset.assetId')
|
||||
.select(columns.syncAsset)
|
||||
.innerJoin('album', 'album.id', 'album_asset.albumId')
|
||||
.leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
|
||||
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)]))
|
||||
.innerJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
|
||||
.where('album_user.userId', '=', userId)
|
||||
.stream();
|
||||
}
|
||||
}
|
||||
@@ -248,9 +250,8 @@ class AlbumAssetExifSync extends BaseSync {
|
||||
.select(columns.syncAssetExif)
|
||||
.select('asset_exif.updateId')
|
||||
.where('album_asset.updateId', '<=', albumToAssetAck.updateId) // Ensure we only send exif updates for assets that the client already knows about
|
||||
.innerJoin('album', 'album.id', 'album_asset.albumId')
|
||||
.leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
|
||||
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)]))
|
||||
.innerJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
|
||||
.where('album_user.userId', '=', userId)
|
||||
.stream();
|
||||
}
|
||||
|
||||
@@ -263,7 +264,7 @@ class AlbumAssetExifSync extends BaseSync {
|
||||
.select(columns.syncAssetExif)
|
||||
.innerJoin('album', 'album.id', 'album_asset.albumId')
|
||||
.leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
|
||||
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)]))
|
||||
.where('album_user.userId', '=', userId)
|
||||
.stream();
|
||||
}
|
||||
}
|
||||
@@ -286,18 +287,7 @@ class AlbumToAssetSync extends BaseSync {
|
||||
eb(
|
||||
'albumId',
|
||||
'in',
|
||||
eb
|
||||
.selectFrom('album')
|
||||
.select(['id'])
|
||||
.where('ownerId', '=', userId)
|
||||
.union((eb) =>
|
||||
eb.parens(
|
||||
eb
|
||||
.selectFrom('album_user')
|
||||
.select(['album_user.albumId as id'])
|
||||
.where('album_user.userId', '=', userId),
|
||||
),
|
||||
),
|
||||
eb.selectFrom('album_user').select(['album_user.albumId as id']).where('album_user.userId', '=', userId),
|
||||
),
|
||||
)
|
||||
.stream();
|
||||
@@ -312,9 +302,8 @@ class AlbumToAssetSync extends BaseSync {
|
||||
const userId = options.userId;
|
||||
return this.upsertQuery('album_asset', options)
|
||||
.select(['album_asset.assetId as assetId', 'album_asset.albumId as albumId', 'album_asset.updateId'])
|
||||
.innerJoin('album', 'album.id', 'album_asset.albumId')
|
||||
.leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
|
||||
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)]))
|
||||
.innerJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
|
||||
.where('album_user.userId', '=', userId)
|
||||
.stream();
|
||||
}
|
||||
}
|
||||
@@ -338,18 +327,7 @@ class AlbumUserSync extends BaseSync {
|
||||
eb(
|
||||
'albumId',
|
||||
'in',
|
||||
eb
|
||||
.selectFrom('album')
|
||||
.select(['id'])
|
||||
.where('ownerId', '=', userId)
|
||||
.union((eb) =>
|
||||
eb.parens(
|
||||
eb
|
||||
.selectFrom('album_user')
|
||||
.select(['album_user.albumId as id'])
|
||||
.where('album_user.userId', '=', userId),
|
||||
),
|
||||
),
|
||||
eb.selectFrom('album_user').select(['album_user.albumId as id']).where('album_user.userId', '=', userId),
|
||||
),
|
||||
)
|
||||
.stream();
|
||||
@@ -370,17 +348,9 @@ class AlbumUserSync extends BaseSync {
|
||||
'album_user.albumId',
|
||||
'in',
|
||||
eb
|
||||
.selectFrom('album')
|
||||
.select(['id'])
|
||||
.where('ownerId', '=', userId)
|
||||
.union((eb) =>
|
||||
eb.parens(
|
||||
eb
|
||||
.selectFrom('album_user as albumUsers')
|
||||
.select(['albumUsers.albumId as id'])
|
||||
.where('albumUsers.userId', '=', userId),
|
||||
),
|
||||
),
|
||||
.selectFrom('album_user as albumUsers')
|
||||
.select(['albumUsers.albumId as id'])
|
||||
.where('albumUsers.userId', '=', userId),
|
||||
),
|
||||
)
|
||||
.stream();
|
||||
|
||||
@@ -11,7 +11,6 @@ import { resourceFromAttributes } from '@opentelemetry/resources';
|
||||
import { AggregationType } from '@opentelemetry/sdk-metrics';
|
||||
import { NodeSDK, contextBase } from '@opentelemetry/sdk-node';
|
||||
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
|
||||
import { ClassConstructor } from 'class-transformer';
|
||||
import { snakeCase, startCase } from 'lodash';
|
||||
import { MetricService } from 'nestjs-otel';
|
||||
import { copyMetadataFromFunctionToFunction } from 'nestjs-otel/lib/opentelemetry.utils';
|
||||
@@ -118,7 +117,7 @@ export class TelemetryRepository {
|
||||
this.repo = new MetricGroupRepository(metricService).configure({ enabled: metrics.has(ImmichTelemetry.Repo) });
|
||||
}
|
||||
|
||||
setup({ repositories }: { repositories: ClassConstructor<unknown>[] }) {
|
||||
setup({ repositories }: { repositories: (new (...args: any[]) => unknown)[] }) {
|
||||
const { telemetry } = this.configRepository.getEnv();
|
||||
const { metrics } = telemetry;
|
||||
if (!metrics.has(ImmichTelemetry.Repo)) {
|
||||
@@ -136,7 +135,7 @@ export class TelemetryRepository {
|
||||
}
|
||||
}
|
||||
|
||||
private wrap(Repository: ClassConstructor<unknown>) {
|
||||
private wrap(Repository: new (...args: any[]) => unknown) {
|
||||
const className = Repository.name;
|
||||
const descriptors = Object.getOwnPropertyDescriptors(Repository.prototype);
|
||||
const unit = 'ms';
|
||||
|
||||
Reference in New Issue
Block a user