fix(server): tighten asset visibility (#18699)

* tighten visibility

* update sql

* elevated access util function

* fix potential sync issue

* include in user stats

* include hidden assets in size usage

* filter visibility in search duplicates query

* stack visibility
This commit is contained in:
Mert 2025-06-02 10:33:08 -04:00 committed by GitHub
parent b5c3a675b2
commit fa22e865a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 132 additions and 107 deletions

View File

@ -73,3 +73,4 @@ where
and "activity"."albumId" = $2 and "activity"."albumId" = $2
and "activity"."isLiked" = $3 and "activity"."isLiked" = $3
and "assets"."deletedAt" is null and "assets"."deletedAt" is null
and "assets"."visibility" != 'locked'

View File

@ -80,6 +80,7 @@ select
where where
"albums_assets_assets"."albumsId" = "albums"."id" "albums_assets_assets"."albumsId" = "albums"."id"
and "assets"."deletedAt" is null and "assets"."deletedAt" is null
and "assets"."visibility" in ('archive', 'timeline')
order by order by
"assets"."fileCreatedAt" desc "assets"."fileCreatedAt" desc
) as "asset" ) as "asset"
@ -178,7 +179,8 @@ from
"assets" "assets"
inner join "albums_assets_assets" as "album_assets" on "album_assets"."assetsId" = "assets"."id" inner join "albums_assets_assets" as "album_assets" on "album_assets"."assetsId" = "assets"."id"
where where
"album_assets"."albumsId" in ($1) "assets"."visibility" in ('archive', 'timeline')
and "album_assets"."albumsId" in ($1)
and "assets"."deletedAt" is null and "assets"."deletedAt" is null
group by group by
"album_assets"."albumsId" "album_assets"."albumsId"

View File

@ -186,8 +186,8 @@ from
inner join "smart_search" on "assets"."id" = "smart_search"."assetId" inner join "smart_search" on "assets"."id" = "smart_search"."assetId"
inner join "asset_job_status" as "job_status" on "job_status"."assetId" = "assets"."id" inner join "asset_job_status" as "job_status" on "job_status"."assetId" = "assets"."id"
where where
"assets"."visibility" != $1 "assets"."deletedAt" is null
and "assets"."deletedAt" is null and "assets"."visibility" in ('archive', 'timeline')
and "job_status"."duplicatesDetectedAt" is null and "job_status"."duplicatesDetectedAt" is null
-- AssetJobRepository.streamForEncodeClip -- AssetJobRepository.streamForEncodeClip
@ -349,7 +349,7 @@ from
"assets" as "stacked" "assets" as "stacked"
where where
"stacked"."deletedAt" is not null "stacked"."deletedAt" is not null
and "stacked"."visibility" != $1 and "stacked"."visibility" = $1
and "stacked"."stackId" = "asset_stack"."id" and "stacked"."stackId" = "asset_stack"."id"
group by group by
"asset_stack"."id" "asset_stack"."id"

View File

@ -130,7 +130,6 @@ select
from from
"assets" "assets"
left join "exif" on "assets"."id" = "exif"."assetId" left join "exif" on "assets"."id" = "exif"."assetId"
left join "asset_stack" on "asset_stack"."id" = "assets"."stackId"
where where
"assets"."id" = any ($1::uuid[]) "assets"."id" = any ($1::uuid[])
@ -240,10 +239,7 @@ with
"assets" "assets"
where where
"assets"."deletedAt" is null "assets"."deletedAt" is null
and ( and "assets"."visibility" in ('archive', 'timeline')
"assets"."visibility" = $1
or "assets"."visibility" = $2
)
) )
select select
"timeBucket", "timeBucket",
@ -300,21 +296,14 @@ with
where where
"stacked"."stackId" = "assets"."stackId" "stacked"."stackId" = "assets"."stackId"
and "stacked"."deletedAt" is null and "stacked"."deletedAt" is null
and "stacked"."visibility" != $1 and "stacked"."visibility" = $1
group by group by
"stacked"."stackId" "stacked"."stackId"
) as "stacked_assets" on true ) as "stacked_assets" on true
where where
"assets"."deletedAt" is null "assets"."deletedAt" is null
and ( and "assets"."visibility" in ('archive', 'timeline')
"assets"."visibility" = $2 and date_trunc('MONTH', "localDateTime" at time zone 'UTC') at time zone 'UTC' = $2
or "assets"."visibility" = $3
)
and date_trunc('MONTH', "localDateTime" at time zone 'UTC') at time zone 'UTC' = $4
and (
"assets"."visibility" = $5
or "assets"."visibility" = $6
)
and not exists ( and not exists (
select select
from from
@ -374,10 +363,10 @@ with
"exif"."assetId" = "assets"."id" "exif"."assetId" = "assets"."id"
) as "asset" on true ) as "asset" on true
where where
"assets"."ownerId" = $1::uuid "assets"."visibility" in ('archive', 'timeline')
and "assets"."ownerId" = $1::uuid
and "assets"."duplicateId" is not null and "assets"."duplicateId" is not null
and "assets"."deletedAt" is null and "assets"."deletedAt" is null
and "assets"."visibility" != $2
and "assets"."stackId" is null and "assets"."stackId" is null
group by group by
"assets"."duplicateId" "assets"."duplicateId"
@ -388,12 +377,12 @@ with
from from
"duplicates" "duplicates"
where where
json_array_length("assets") = $3 json_array_length("assets") = $2
), ),
"removed_unique" as ( "removed_unique" as (
update "assets" update "assets"
set set
"duplicateId" = $4 "duplicateId" = $3
from from
"unique" "unique"
where where

View File

@ -182,27 +182,42 @@ from
"asset_faces" "asset_faces"
left join "assets" on "assets"."id" = "asset_faces"."assetId" left join "assets" on "assets"."id" = "asset_faces"."assetId"
and "asset_faces"."personId" = $1 and "asset_faces"."personId" = $1
and "assets"."visibility" != $2 and "assets"."visibility" = 'timeline'
and "assets"."deletedAt" is null and "assets"."deletedAt" is null
where where
"asset_faces"."deletedAt" is null "asset_faces"."deletedAt" is null
-- PersonRepository.getNumberOfPeople -- PersonRepository.getNumberOfPeople
select select
count(distinct ("person"."id")) as "total", coalesce(count(*), 0) as "total",
count(distinct ("person"."id")) filter ( coalesce(
count(*) filter (
where where
"person"."isHidden" = $1 "isHidden" = $1
),
0
) as "hidden" ) as "hidden"
from from
"person" "person"
inner join "asset_faces" on "asset_faces"."personId" = "person"."id"
inner join "assets" on "assets"."id" = "asset_faces"."assetId"
and "assets"."deletedAt" is null
and "assets"."visibility" != $2
where where
"person"."ownerId" = $3 exists (
select
from
"asset_faces"
where
"asset_faces"."personId" = "person"."id"
and "asset_faces"."deletedAt" is null and "asset_faces"."deletedAt" is null
and exists (
select
from
"assets"
where
"assets"."id" = "asset_faces"."assetId"
and "assets"."visibility" = 'timeline'
and "assets"."deletedAt" is null
)
)
and "person"."ownerId" = $2
-- PersonRepository.refreshFaces -- PersonRepository.refreshFaces
with with

View File

@ -102,23 +102,23 @@ with
"assets" "assets"
inner join "smart_search" on "assets"."id" = "smart_search"."assetId" inner join "smart_search" on "assets"."id" = "smart_search"."assetId"
where where
"assets"."ownerId" = any ($2::uuid[]) "assets"."visibility" in ('archive', 'timeline')
and "assets"."ownerId" = any ($2::uuid[])
and "assets"."deletedAt" is null and "assets"."deletedAt" is null
and "assets"."visibility" != $3 and "assets"."type" = $3
and "assets"."type" = $4 and "assets"."id" != $4::uuid
and "assets"."id" != $5::uuid
and "assets"."stackId" is null and "assets"."stackId" is null
order by order by
"distance" "distance"
limit limit
$6 $5
) )
select select
* *
from from
"cte" "cte"
where where
"cte"."distance" <= $7 "cte"."distance" <= $6
commit commit
-- SearchRepository.searchFaces -- SearchRepository.searchFaces
@ -241,7 +241,7 @@ from
inner join "assets" on "assets"."id" = "exif"."assetId" inner join "assets" on "assets"."id" = "exif"."assetId"
where where
"ownerId" = any ($1::uuid[]) "ownerId" = any ($1::uuid[])
and "visibility" != $2 and "visibility" = $2
and "deletedAt" is null and "deletedAt" is null
and "state" is not null and "state" is not null
@ -253,7 +253,7 @@ from
inner join "assets" on "assets"."id" = "exif"."assetId" inner join "assets" on "assets"."id" = "exif"."assetId"
where where
"ownerId" = any ($1::uuid[]) "ownerId" = any ($1::uuid[])
and "visibility" != $2 and "visibility" = $2
and "deletedAt" is null and "deletedAt" is null
and "city" is not null and "city" is not null
@ -265,7 +265,7 @@ from
inner join "assets" on "assets"."id" = "exif"."assetId" inner join "assets" on "assets"."id" = "exif"."assetId"
where where
"ownerId" = any ($1::uuid[]) "ownerId" = any ($1::uuid[])
and "visibility" != $2 and "visibility" = $2
and "deletedAt" is null and "deletedAt" is null
and "make" is not null and "make" is not null
@ -277,6 +277,6 @@ from
inner join "assets" on "assets"."id" = "exif"."assetId" inner join "assets" on "assets"."id" = "exif"."assetId"
where where
"ownerId" = any ($1::uuid[]) "ownerId" = any ($1::uuid[])
and "visibility" != $2 and "visibility" = $2
and "deletedAt" is null and "deletedAt" is null
and "model" is not null and "model" is not null

View File

@ -52,6 +52,7 @@ select
where where
"assets"."deletedAt" is null "assets"."deletedAt" is null
and "assets"."stackId" = "asset_stack"."id" and "assets"."stackId" = "asset_stack"."id"
and "assets"."visibility" in ('archive', 'timeline')
) as agg ) as agg
) as "assets" ) as "assets"
from from
@ -135,6 +136,7 @@ select
where where
"assets"."deletedAt" is null "assets"."deletedAt" is null
and "assets"."stackId" = "asset_stack"."id" and "assets"."stackId" = "asset_stack"."id"
and "assets"."visibility" in ('archive', 'timeline')
) as agg ) as agg
) as "assets" ) as "assets"
from from

View File

@ -290,7 +290,7 @@ order by
select select
"users"."id" as "userId", "users"."id" as "userId",
"users"."name" as "userName", "users"."name" as "userName",
"users"."quotaSizeInBytes" as "quotaSizeInBytes", "users"."quotaSizeInBytes",
count(*) filter ( count(*) filter (
where where
( (
@ -335,9 +335,8 @@ select
from from
"users" "users"
left join "assets" on "assets"."ownerId" = "users"."id" left join "assets" on "assets"."ownerId" = "users"."id"
and "assets"."deletedAt" is null
left join "exif" on "exif"."assetId" = "assets"."id" left join "exif" on "exif"."assetId" = "assets"."id"
where
"assets"."deletedAt" is null
group by group by
"users"."id" "users"."id"
order by order by

View File

@ -5,6 +5,7 @@ import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database'; import { columns } from 'src/database';
import { Activity, DB } from 'src/db'; import { Activity, DB } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetVisibility } from 'src/enum';
import { asUuid } from 'src/utils/database'; import { asUuid } from 'src/utils/database';
export interface ActivitySearch { export interface ActivitySearch {
@ -76,6 +77,7 @@ export class ActivityRepository {
.where('activity.albumId', '=', albumId) .where('activity.albumId', '=', albumId)
.where('activity.isLiked', '=', false) .where('activity.isLiked', '=', false)
.where('assets.deletedAt', 'is', null) .where('assets.deletedAt', 'is', null)
.where('assets.visibility', '!=', sql.lit(AssetVisibility.LOCKED))
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
return count; return count;

View File

@ -6,6 +6,7 @@ import { columns, Exif } from 'src/database';
import { Albums, DB } from 'src/db'; import { Albums, DB } from 'src/db';
import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { AlbumUserCreateDto } from 'src/dtos/album.dto'; import { AlbumUserCreateDto } from 'src/dtos/album.dto';
import { withDefaultVisibility } from 'src/utils/database';
export interface AlbumAssetCount { export interface AlbumAssetCount {
albumId: string; albumId: string;
@ -58,6 +59,7 @@ const withAssets = (eb: ExpressionBuilder<DB, 'albums'>) => {
.innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id') .innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id')
.whereRef('albums_assets_assets.albumsId', '=', 'albums.id') .whereRef('albums_assets_assets.albumsId', '=', 'albums.id')
.where('assets.deletedAt', 'is', null) .where('assets.deletedAt', 'is', null)
.$call(withDefaultVisibility)
.orderBy('assets.fileCreatedAt', 'desc') .orderBy('assets.fileCreatedAt', 'desc')
.as('asset'), .as('asset'),
) )
@ -121,6 +123,7 @@ export class AlbumRepository {
return ( return (
this.db this.db
.selectFrom('assets') .selectFrom('assets')
.$call(withDefaultVisibility)
.innerJoin('albums_assets_assets as album_assets', 'album_assets.assetsId', 'assets.id') .innerJoin('albums_assets_assets as album_assets', 'album_assets.assetsId', 'assets.id')
.select('album_assets.albumsId as albumId') .select('album_assets.albumsId as albumId')
.select((eb) => eb.fn.min(sql<Date>`("assets"."localDateTime" AT TIME ZONE 'UTC'::text)::date`).as('startDate')) .select((eb) => eb.fn.min(sql<Date>`("assets"."localDateTime" AT TIME ZONE 'UTC'::text)::date`).as('startDate'))

View File

@ -11,6 +11,7 @@ import {
anyUuid, anyUuid,
asUuid, asUuid,
toJson, toJson,
withDefaultVisibility,
withExif, withExif,
withExifInner, withExifInner,
withFaces, withFaces,
@ -140,9 +141,9 @@ export class AssetJobRepository {
return this.db return this.db
.selectFrom('assets') .selectFrom('assets')
.select(['assets.id']) .select(['assets.id'])
.where('assets.visibility', '!=', AssetVisibility.HIDDEN)
.where('assets.deletedAt', 'is', null) .where('assets.deletedAt', 'is', null)
.innerJoin('smart_search', 'assets.id', 'smart_search.assetId') .innerJoin('smart_search', 'assets.id', 'smart_search.assetId')
.$call(withDefaultVisibility)
.$if(!force, (qb) => .$if(!force, (qb) =>
qb qb
.innerJoin('asset_job_status as job_status', 'job_status.assetId', 'assets.id') .innerJoin('asset_job_status as job_status', 'job_status.assetId', 'assets.id')
@ -226,7 +227,7 @@ export class AssetJobRepository {
.select(['asset_stack.id', 'asset_stack.primaryAssetId']) .select(['asset_stack.id', 'asset_stack.primaryAssetId'])
.select((eb) => eb.fn<Asset[]>('array_agg', [eb.table('stacked')]).as('assets')) .select((eb) => eb.fn<Asset[]>('array_agg', [eb.table('stacked')]).as('assets'))
.where('stacked.deletedAt', 'is not', null) .where('stacked.deletedAt', 'is not', null)
.where('stacked.visibility', '!=', AssetVisibility.ARCHIVE) .where('stacked.visibility', '=', AssetVisibility.TIMELINE)
.whereRef('stacked.stackId', '=', 'asset_stack.id') .whereRef('stacked.stackId', '=', 'asset_stack.id')
.groupBy('asset_stack.id') .groupBy('asset_stack.id')
.as('stacked_assets'), .as('stacked_assets'),

View File

@ -300,7 +300,6 @@ export class AssetRepository {
.select(withFacesAndPeople) .select(withFacesAndPeople)
.select(withTags) .select(withTags)
.$call(withExif) .$call(withExif)
.leftJoin('asset_stack', 'asset_stack.id', 'assets.stackId')
.where('assets.id', '=', anyUuid(ids)) .where('assets.id', '=', anyUuid(ids))
.execute(); .execute();
} }
@ -523,8 +522,8 @@ export class AssetRepository {
.selectFrom('assets') .selectFrom('assets')
.selectAll('assets') .selectAll('assets')
.$call(withExif) .$call(withExif)
.$call(withDefaultVisibility)
.where('ownerId', '=', anyUuid(userIds)) .where('ownerId', '=', anyUuid(userIds))
.where('visibility', '!=', AssetVisibility.HIDDEN)
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
.orderBy((eb) => eb.fn('random')) .orderBy((eb) => eb.fn('random'))
.limit(take) .limit(take)
@ -634,8 +633,6 @@ export class AssetRepository {
) )
.$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!])) .$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!]))
.$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!))) .$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!)))
.$if(options.visibility == undefined, withDefaultVisibility)
.$if(!!options.visibility, (qb) => qb.where('assets.visibility', '=', options.visibility!))
.$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!)) .$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!))
.$if(!!options.withStacked, (qb) => .$if(!!options.withStacked, (qb) =>
qb qb
@ -656,7 +653,7 @@ export class AssetRepository {
.select(sql`array[stacked."stackId"::text, count('stacked')::text]`.as('stack')) .select(sql`array[stacked."stackId"::text, count('stacked')::text]`.as('stack'))
.whereRef('stacked.stackId', '=', 'assets.stackId') .whereRef('stacked.stackId', '=', 'assets.stackId')
.where('stacked.deletedAt', 'is', null) .where('stacked.deletedAt', 'is', null)
.where('stacked.visibility', '!=', AssetVisibility.ARCHIVE) .where('stacked.visibility', '=', AssetVisibility.TIMELINE)
.groupBy('stacked.stackId') .groupBy('stacked.stackId')
.as('stacked_assets'), .as('stacked_assets'),
(join) => join.onTrue(), (join) => join.onTrue(),
@ -709,6 +706,7 @@ export class AssetRepository {
.with('duplicates', (qb) => .with('duplicates', (qb) =>
qb qb
.selectFrom('assets') .selectFrom('assets')
.$call(withDefaultVisibility)
.leftJoinLateral( .leftJoinLateral(
(qb) => (qb) =>
qb qb
@ -727,7 +725,6 @@ export class AssetRepository {
.where('assets.duplicateId', 'is not', null) .where('assets.duplicateId', 'is not', null)
.$narrowType<{ duplicateId: NotNull }>() .$narrowType<{ duplicateId: NotNull }>()
.where('assets.deletedAt', 'is', null) .where('assets.deletedAt', 'is', null)
.where('assets.visibility', '!=', AssetVisibility.HIDDEN)
.where('assets.stackId', 'is', null) .where('assets.stackId', 'is', null)
.groupBy('assets.duplicateId'), .groupBy('assets.duplicateId'),
) )

View File

@ -38,11 +38,6 @@ export interface PersonStatistics {
assets: number; assets: number;
} }
export interface PeopleStatistics {
total: number;
hidden: number;
}
export interface DeleteFacesOptions { export interface DeleteFacesOptions {
sourceType: SourceType; sourceType: SourceType;
} }
@ -151,7 +146,7 @@ export class PersonRepository {
.innerJoin('assets', (join) => .innerJoin('assets', (join) =>
join join
.onRef('asset_faces.assetId', '=', 'assets.id') .onRef('asset_faces.assetId', '=', 'assets.id')
.on('assets.visibility', '!=', AssetVisibility.ARCHIVE) .on('assets.visibility', '=', sql.lit(AssetVisibility.TIMELINE))
.on('assets.deletedAt', 'is', null), .on('assets.deletedAt', 'is', null),
) )
.where('person.ownerId', '=', userId) .where('person.ownerId', '=', userId)
@ -341,7 +336,7 @@ export class PersonRepository {
join join
.onRef('assets.id', '=', 'asset_faces.assetId') .onRef('assets.id', '=', 'asset_faces.assetId')
.on('asset_faces.personId', '=', personId) .on('asset_faces.personId', '=', personId)
.on('assets.visibility', '!=', AssetVisibility.ARCHIVE) .on('assets.visibility', '=', sql.lit(AssetVisibility.TIMELINE))
.on('assets.deletedAt', 'is', null), .on('assets.deletedAt', 'is', null),
) )
.select((eb) => eb.fn.count(eb.fn('distinct', ['assets.id'])).as('count')) .select((eb) => eb.fn.count(eb.fn('distinct', ['assets.id'])).as('count'))
@ -354,35 +349,31 @@ export class PersonRepository {
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
async getNumberOfPeople(userId: string): Promise<PeopleStatistics> { getNumberOfPeople(userId: string) {
const items = await this.db const zero = sql.lit(0);
return this.db
.selectFrom('person') .selectFrom('person')
.innerJoin('asset_faces', 'asset_faces.personId', 'person.id') .where((eb) =>
.where('person.ownerId', '=', userId) eb.exists((eb) =>
eb
.selectFrom('asset_faces')
.whereRef('asset_faces.personId', '=', 'person.id')
.where('asset_faces.deletedAt', 'is', null) .where('asset_faces.deletedAt', 'is', null)
.innerJoin('assets', (join) => .where((eb) =>
join eb.exists((eb) =>
.onRef('assets.id', '=', 'asset_faces.assetId') eb
.on('assets.deletedAt', 'is', null) .selectFrom('assets')
.on('assets.visibility', '!=', AssetVisibility.ARCHIVE), .whereRef('assets.id', '=', 'asset_faces.assetId')
.where('assets.visibility', '=', sql.lit(AssetVisibility.TIMELINE))
.where('assets.deletedAt', 'is', null),
),
),
),
) )
.select((eb) => eb.fn.count(eb.fn('distinct', ['person.id'])).as('total')) .where('person.ownerId', '=', userId)
.select((eb) => .select((eb) => eb.fn.coalesce(eb.fn.countAll<number>(), zero).as('total'))
eb.fn .select((eb) => eb.fn.coalesce(eb.fn.countAll<number>().filterWhere('isHidden', '=', true), zero).as('hidden'))
.count(eb.fn('distinct', ['person.id'])) .executeTakeFirstOrThrow();
.filterWhere('person.isHidden', '=', true)
.as('hidden'),
)
.executeTakeFirst();
if (items == undefined) {
return { total: 0, hidden: 0 };
}
return {
total: Number(items.total),
hidden: Number(items.hidden),
};
} }
create(person: Insertable<Person>) { create(person: Insertable<Person>) {

View File

@ -7,7 +7,7 @@ import { DummyValue, GenerateSql } from 'src/decorators';
import { MapAsset } from 'src/dtos/asset-response.dto'; import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetStatus, AssetType, AssetVisibility, VectorIndex } from 'src/enum'; import { AssetStatus, AssetType, AssetVisibility, VectorIndex } from 'src/enum';
import { probes } from 'src/repositories/database.repository'; import { probes } from 'src/repositories/database.repository';
import { anyUuid, asUuid, searchAssetBuilder } from 'src/utils/database'; import { anyUuid, asUuid, searchAssetBuilder, withDefaultVisibility } from 'src/utils/database';
import { paginationHelper } from 'src/utils/pagination'; import { paginationHelper } from 'src/utils/pagination';
import { isValidInteger } from 'src/validation'; import { isValidInteger } from 'src/validation';
@ -268,6 +268,7 @@ export class SearchRepository {
.with('cte', (qb) => .with('cte', (qb) =>
qb qb
.selectFrom('assets') .selectFrom('assets')
.$call(withDefaultVisibility)
.select([ .select([
'assets.id as assetId', 'assets.id as assetId',
'assets.duplicateId', 'assets.duplicateId',
@ -276,7 +277,6 @@ export class SearchRepository {
.innerJoin('smart_search', 'assets.id', 'smart_search.assetId') .innerJoin('smart_search', 'assets.id', 'smart_search.assetId')
.where('assets.ownerId', '=', anyUuid(userIds)) .where('assets.ownerId', '=', anyUuid(userIds))
.where('assets.deletedAt', 'is', null) .where('assets.deletedAt', 'is', null)
.where('assets.visibility', '!=', AssetVisibility.HIDDEN)
.where('assets.type', '=', type) .where('assets.type', '=', type)
.where('assets.id', '!=', asUuid(assetId)) .where('assets.id', '!=', asUuid(assetId))
.where('assets.stackId', 'is', null) .where('assets.stackId', 'is', null)
@ -472,7 +472,7 @@ export class SearchRepository {
.distinctOn(field) .distinctOn(field)
.innerJoin('assets', 'assets.id', 'exif.assetId') .innerJoin('assets', 'assets.id', 'exif.assetId')
.where('ownerId', '=', anyUuid(userIds)) .where('ownerId', '=', anyUuid(userIds))
.where('visibility', '!=', AssetVisibility.HIDDEN) .where('visibility', '=', AssetVisibility.TIMELINE)
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
.where(field, 'is not', null); .where(field, 'is not', null);
} }

View File

@ -5,7 +5,7 @@ import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database'; import { columns } from 'src/database';
import { AssetStack, DB } from 'src/db'; import { AssetStack, DB } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { asUuid } from 'src/utils/database'; import { asUuid, withDefaultVisibility } from 'src/utils/database';
export interface StackSearch { export interface StackSearch {
ownerId: string; ownerId: string;
@ -34,7 +34,8 @@ const withAssets = (eb: ExpressionBuilder<DB, 'asset_stack'>, withTags = false)
) )
.select((eb) => eb.fn.toJson('exifInfo').as('exifInfo')) .select((eb) => eb.fn.toJson('exifInfo').as('exifInfo'))
.where('assets.deletedAt', 'is', null) .where('assets.deletedAt', 'is', null)
.whereRef('assets.stackId', '=', 'asset_stack.id'), .whereRef('assets.stackId', '=', 'asset_stack.id')
.$call(withDefaultVisibility),
).as('assets'); ).as('assets');
}; };

View File

@ -210,9 +210,9 @@ export class UserRepository {
getUserStats() { getUserStats() {
return this.db return this.db
.selectFrom('users') .selectFrom('users')
.leftJoin('assets', 'assets.ownerId', 'users.id') .leftJoin('assets', (join) => join.onRef('assets.ownerId', '=', 'users.id').on('assets.deletedAt', 'is', null))
.leftJoin('exif', 'exif.assetId', 'assets.id') .leftJoin('exif', 'exif.assetId', 'assets.id')
.select(['users.id as userId', 'users.name as userName', 'users.quotaSizeInBytes as quotaSizeInBytes']) .select(['users.id as userId', 'users.name as userName', 'users.quotaSizeInBytes'])
.select((eb) => [ .select((eb) => [
eb.fn eb.fn
.countAll<number>() .countAll<number>()
@ -256,7 +256,6 @@ export class UserRepository {
) )
.as('usageVideos'), .as('usageVideos'),
]) ])
.where('assets.deletedAt', 'is', null)
.groupBy('users.id') .groupBy('users.id')
.orderBy('users.createdAt', 'asc') .orderBy('users.createdAt', 'asc')
.execute(); .execute();

View File

@ -17,11 +17,16 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { ISidecarWriteJob, JobItem, JobOf } from 'src/types'; import { ISidecarWriteJob, JobItem, JobOf } from 'src/types';
import { requireElevatedPermission } from 'src/utils/access';
import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util'; import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util';
@Injectable() @Injectable()
export class AssetService extends BaseService { export class AssetService extends BaseService {
async getStatistics(auth: AuthDto, dto: AssetStatsDto) { async getStatistics(auth: AuthDto, dto: AssetStatsDto) {
if (dto.visibility === AssetVisibility.LOCKED) {
requireElevatedPermission(auth);
}
const stats = await this.assetRepository.getStatistics(auth.user.id, dto); const stats = await this.assetRepository.getStatistics(auth.user.id, dto);
return mapStats(stats); return mapStats(stats);
} }

View File

@ -14,8 +14,9 @@ import {
SearchSuggestionType, SearchSuggestionType,
SmartSearchDto, SmartSearchDto,
} from 'src/dtos/search.dto'; } from 'src/dtos/search.dto';
import { AssetOrder } from 'src/enum'; import { AssetOrder, AssetVisibility } from 'src/enum';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { requireElevatedPermission } from 'src/utils/access';
import { getMyPartnerIds } from 'src/utils/asset.util'; import { getMyPartnerIds } from 'src/utils/asset.util';
import { isSmartSearchEnabled } from 'src/utils/misc'; import { isSmartSearchEnabled } from 'src/utils/misc';
@ -40,9 +41,11 @@ export class SearchService extends BaseService {
} }
async searchMetadata(auth: AuthDto, dto: MetadataSearchDto): Promise<SearchResponseDto> { async searchMetadata(auth: AuthDto, dto: MetadataSearchDto): Promise<SearchResponseDto> {
let checksum: Buffer | undefined; if (dto.visibility === AssetVisibility.LOCKED) {
const userIds = await this.getUserIdsToSearch(auth); requireElevatedPermission(auth);
}
let checksum: Buffer | undefined;
if (dto.checksum) { if (dto.checksum) {
const encoding = dto.checksum.length === 28 ? 'base64' : 'hex'; const encoding = dto.checksum.length === 28 ? 'base64' : 'hex';
checksum = Buffer.from(dto.checksum, encoding); checksum = Buffer.from(dto.checksum, encoding);
@ -50,6 +53,7 @@ export class SearchService extends BaseService {
const page = dto.page ?? 1; const page = dto.page ?? 1;
const size = dto.size || 250; const size = dto.size || 250;
const userIds = await this.getUserIdsToSearch(auth);
const { hasNextPage, items } = await this.searchRepository.searchMetadata( const { hasNextPage, items } = await this.searchRepository.searchMetadata(
{ page, size }, { page, size },
{ {
@ -64,12 +68,20 @@ export class SearchService extends BaseService {
} }
async searchRandom(auth: AuthDto, dto: RandomSearchDto): Promise<AssetResponseDto[]> { async searchRandom(auth: AuthDto, dto: RandomSearchDto): Promise<AssetResponseDto[]> {
if (dto.visibility === AssetVisibility.LOCKED) {
requireElevatedPermission(auth);
}
const userIds = await this.getUserIdsToSearch(auth); const userIds = await this.getUserIdsToSearch(auth);
const items = await this.searchRepository.searchRandom(dto.size || 250, { ...dto, userIds }); const items = await this.searchRepository.searchRandom(dto.size || 250, { ...dto, userIds });
return items.map((item) => mapAsset(item, { auth })); return items.map((item) => mapAsset(item, { auth }));
} }
async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise<SearchResponseDto> { async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise<SearchResponseDto> {
if (dto.visibility === AssetVisibility.LOCKED) {
requireElevatedPermission(auth);
}
const { machineLearning } = await this.getConfig({ withCache: false }); const { machineLearning } = await this.getConfig({ withCache: false });
if (!isSmartSearchEnabled(machineLearning)) { if (!isSmartSearchEnabled(machineLearning)) {
throw new BadRequestException('Smart search is not enabled'); throw new BadRequestException('Smart search is not enabled');

View File

@ -4,6 +4,7 @@ import { TimeBucketAssetDto, TimeBucketDto, TimeBucketsResponseDto } from 'src/d
import { AssetVisibility, Permission } from 'src/enum'; import { AssetVisibility, Permission } from 'src/enum';
import { TimeBucketOptions } from 'src/repositories/asset.repository'; import { TimeBucketOptions } from 'src/repositories/asset.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { requireElevatedPermission } from 'src/utils/access';
import { getMyPartnerIds } from 'src/utils/asset.util'; import { getMyPartnerIds } from 'src/utils/asset.util';
@Injectable() @Injectable()
@ -44,6 +45,10 @@ export class TimelineService extends BaseService {
} }
private async timeBucketChecks(auth: AuthDto, dto: TimeBucketDto) { private async timeBucketChecks(auth: AuthDto, dto: TimeBucketDto) {
if (dto.visibility === AssetVisibility.LOCKED) {
requireElevatedPermission(auth);
}
if (dto.albumId) { if (dto.albumId) {
await this.requireAccess({ auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] }); await this.requireAccess({ auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] });
} else { } else {

View File

@ -304,3 +304,9 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
} }
} }
}; };
export const requireElevatedPermission = (auth: AuthDto) => {
if (!auth.session?.hasElevatedPermission) {
throw new UnauthorizedException('Elevated permission is required');
}
};

View File

@ -153,17 +153,12 @@ export function toJson<DB, TB extends keyof DB & string, T extends TB | Expressi
} }
export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum'; export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum';
// TODO come up with a better query that only selects the fields we need
export function withDefaultVisibility<O>(qb: SelectQueryBuilder<DB, 'assets', O>) { export function withDefaultVisibility<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
return qb.where((qb) => return qb.where('assets.visibility', 'in', [sql.lit(AssetVisibility.ARCHIVE), sql.lit(AssetVisibility.TIMELINE)]);
qb.or([
qb('assets.visibility', '=', AssetVisibility.TIMELINE),
qb('assets.visibility', '=', AssetVisibility.ARCHIVE),
]),
);
} }
// TODO come up with a better query that only selects the fields we need
export function withExif<O>(qb: SelectQueryBuilder<DB, 'assets', O>) { export function withExif<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
return qb return qb
.leftJoin('exif', 'assets.id', 'exif.assetId') .leftJoin('exif', 'assets.id', 'exif.assetId')