Merge branch 'main' into feat/mobile-ocr

This commit is contained in:
Yaros
2026-04-22 18:08:10 +02:00
1565 changed files with 43096 additions and 79581 deletions
+19 -9
View File
@@ -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'))
+167 -121
View File
@@ -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([
+2 -125
View File
@@ -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');
});
});
+8 -9
View File
@@ -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, []);
+2 -3
View File
@@ -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();
-2
View File
@@ -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,
+1 -2
View File
@@ -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
+31 -25
View File
@@ -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}`);
+75 -5
View File
@@ -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,
},
);
+2 -2
View File
@@ -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 }) {
+3 -16
View File
@@ -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({
+23 -1
View File
@@ -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');
};
+20 -50
View File
@@ -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';