diff --git a/server/src/database.ts b/server/src/database.ts index f8af8438a0..33a877102f 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -163,6 +163,28 @@ export type Partner = { inTimeline: boolean; }; +export type Place = { + admin1Code: string | null; + admin1Name: string | null; + admin2Code: string | null; + admin2Name: string | null; + alternateNames: string | null; + countryCode: string; + id: number; + latitude: number; + longitude: number; + modificationDate: Date; + name: string; +}; + +export type Session = { + id: string; + createdAt: Date; + updatedAt: Date; + deviceOS: string; + deviceType: string; +}; + const userColumns = ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'] as const; export const columns = { diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 67ab059e11..a7633dce78 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -1,11 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator'; +import { Place } from 'src/database'; import { PropertyLifecycle } from 'src/decorators'; import { AlbumResponseDto } from 'src/dtos/album.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AssetOrder, AssetType } from 'src/enum'; -import { SearchPlacesItem } from 'src/types'; import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; class BaseSearchDto { @@ -226,7 +226,7 @@ export class PlacesResponseDto { admin2name?: string; } -export function mapPlaces(place: SearchPlacesItem): PlacesResponseDto { +export function mapPlaces(place: Place): PlacesResponseDto { return { name: place.name, latitude: place.latitude, diff --git a/server/src/dtos/session.dto.ts b/server/src/dtos/session.dto.ts index dab1bf62b5..b54264a5b4 100644 --- a/server/src/dtos/session.dto.ts +++ b/server/src/dtos/session.dto.ts @@ -1,4 +1,4 @@ -import { SessionItem } from 'src/types'; +import { Session } from 'src/database'; export class SessionResponseDto { id!: string; @@ -9,7 +9,7 @@ export class SessionResponseDto { deviceOS!: string; } -export const mapSession = (entity: SessionItem, currentId?: string): SessionResponseDto => ({ +export const mapSession = (entity: Session, currentId?: string): SessionResponseDto => ({ id: entity.id, createdAt: entity.createdAt.toISOString(), updatedAt: entity.updatedAt.toISOString(), diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql index fee3bcbd9d..eea2356897 100644 --- a/server/src/queries/session.repository.sql +++ b/server/src/queries/session.repository.sql @@ -38,42 +38,11 @@ where -- SessionRepository.getByUserId select - "sessions".*, - to_json("user") as "user" + "sessions".* from "sessions" - inner join lateral ( - select - "id", - "name", - "email", - "profileImagePath", - "profileChangedAt", - "createdAt", - "updatedAt", - "deletedAt", - "isAdmin", - "status", - "oauthId", - "profileImagePath", - "shouldChangePassword", - "storageLabel", - "quotaSizeInBytes", - "quotaUsageInBytes", - ( - select - array_agg("user_metadata") as "metadata" - from - "user_metadata" - where - "users"."id" = "user_metadata"."userId" - ) as "metadata" - from - "users" - where - "users"."id" = "sessions"."userId" - and "users"."deletedAt" is null - ) as "user" on true + inner join "users" on "users"."id" = "sessions"."userId" + and "users"."deletedAt" is null where "sessions"."userId" = $1 order by diff --git a/server/src/repositories/session.repository.ts b/server/src/repositories/session.repository.ts index 390e732c6b..742807dc9c 100644 --- a/server/src/repositories/session.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { ExpressionBuilder, Insertable, Kysely, Updateable } from 'kysely'; +import { Insertable, Kysely, Updateable } from 'kysely'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; import { columns } from 'src/database'; @@ -9,22 +9,6 @@ import { asUuid } from 'src/utils/database'; export type SessionSearchOptions = { updatedBefore: Date }; -const withUser = (eb: ExpressionBuilder) => { - return eb - .selectFrom('users') - .select(columns.userAdmin) - .select((eb) => - eb - .selectFrom('user_metadata') - .whereRef('users.id', '=', 'user_metadata.userId') - .select((eb) => eb.fn('array_agg', [eb.table('user_metadata')]).as('metadata')) - .as('metadata'), - ) - .whereRef('users.id', '=', 'sessions.userId') - .where('users.deletedAt', 'is', null) - .as('user'); -}; - @Injectable() export class SessionRepository { constructor(@InjectKysely() private db: Kysely) {} @@ -60,9 +44,8 @@ export class SessionRepository { getByUserId(userId: string) { return this.db .selectFrom('sessions') - .innerJoinLateral(withUser, (join) => join.onTrue()) + .innerJoin('users', (join) => join.onRef('users.id', '=', 'sessions.userId').on('users.deletedAt', 'is', null)) .selectAll('sessions') - .select((eb) => eb.fn.toJson('user').as('user')) .where('sessions.userId', '=', userId) .orderBy('sessions.updatedAt', 'desc') .orderBy('sessions.createdAt', 'desc') diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index b1bd3332bf..0c5ad3099d 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -1,10 +1,10 @@ import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; +import { DateTime } from 'luxon'; import { AuthDto, SignUpDto } from 'src/dtos/auth.dto'; import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; import { AuthType, Permission } from 'src/enum'; import { AuthService } from 'src/services/auth.service'; -import { sessionStub } from 'test/fixtures/session.stub'; import { sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { userStub } from 'test/fixtures/user.stub'; @@ -97,17 +97,19 @@ describe('AuthService', () => { }); it('should successfully log the user in', async () => { - mocks.user.getByEmail.mockResolvedValue(userStub.user1); - mocks.session.create.mockResolvedValue(sessionStub.valid); + const user = { ...factory.user(), password: 'immich_password' } as UserEntity; + const session = factory.session(); + mocks.user.getByEmail.mockResolvedValue(user); + mocks.session.create.mockResolvedValue(session); await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual({ accessToken: 'cmFuZG9tLWJ5dGVz', - userId: 'user-id', - userEmail: 'immich@test.com', - name: 'immich_name', - profileImagePath: '', - isAdmin: false, - shouldChangePassword: false, + userId: user.id, + userEmail: user.email, + name: user.name, + profileImagePath: user.profileImagePath, + isAdmin: user.isAdmin, + shouldChangePassword: user.shouldChangePassword, }); expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); @@ -256,8 +258,14 @@ describe('AuthService', () => { }); it('should validate using authorization header', async () => { - mocks.user.get.mockResolvedValue(userStub.user1); - mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any); + const session = factory.session(); + const sessionWithToken = { + id: session.id, + updatedAt: session.updatedAt, + user: factory.authUser(), + }; + + mocks.session.getByToken.mockResolvedValue(sessionWithToken); await expect( sut.authenticate({ @@ -266,8 +274,8 @@ describe('AuthService', () => { metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, }), ).resolves.toEqual({ - user: userStub.user1, - session: sessionStub.valid, + user: sessionWithToken.user, + session: { id: session.id }, }); }); }); @@ -371,7 +379,14 @@ describe('AuthService', () => { }); it('should return an auth dto', async () => { - mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any); + const session = factory.session(); + const sessionWithToken = { + id: session.id, + updatedAt: session.updatedAt, + user: factory.authUser(), + }; + + mocks.session.getByToken.mockResolvedValue(sessionWithToken); await expect( sut.authenticate({ @@ -380,13 +395,20 @@ describe('AuthService', () => { metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, }), ).resolves.toEqual({ - user: userStub.user1, - session: sessionStub.valid, + user: sessionWithToken.user, + session: { id: session.id }, }); }); it('should throw if admin route and not an admin', async () => { - mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any); + const session = factory.session(); + const sessionWithToken = { + id: session.id, + updatedAt: session.updatedAt, + user: factory.authUser(), + }; + + mocks.session.getByToken.mockResolvedValue(sessionWithToken); await expect( sut.authenticate({ @@ -398,8 +420,15 @@ describe('AuthService', () => { }); it('should update when access time exceeds an hour', async () => { - mocks.session.getByToken.mockResolvedValue(sessionStub.inactive as any); - mocks.session.update.mockResolvedValue(sessionStub.valid); + const session = factory.session({ updatedAt: DateTime.now().minus({ hours: 2 }).toJSDate() }); + const sessionWithToken = { + id: session.id, + updatedAt: session.updatedAt, + user: factory.authUser(), + }; + + mocks.session.getByToken.mockResolvedValue(sessionWithToken); + mocks.session.update.mockResolvedValue(session); await expect( sut.authenticate({ @@ -408,7 +437,8 @@ describe('AuthService', () => { metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, }), ).resolves.toBeDefined(); - expect(mocks.session.update.mock.calls[0][1]).toMatchObject({ id: 'not_active', updatedAt: expect.any(Date) }); + + expect(mocks.session.update).toHaveBeenCalled(); }); }); @@ -506,7 +536,7 @@ describe('AuthService', () => { mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.user.getByEmail.mockResolvedValue(userStub.user1); mocks.user.update.mockResolvedValue(userStub.user1); - mocks.session.create.mockResolvedValue(sessionStub.valid); + mocks.session.create.mockResolvedValue(factory.session()); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( oauthResponse, @@ -535,7 +565,7 @@ describe('AuthService', () => { mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getAdmin.mockResolvedValue(userStub.user1); mocks.user.create.mockResolvedValue(userStub.user1); - mocks.session.create.mockResolvedValue(sessionStub.valid); + mocks.session.create.mockResolvedValue(factory.session()); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( oauthResponse, @@ -550,7 +580,7 @@ describe('AuthService', () => { mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getAdmin.mockResolvedValue(userStub.user1); mocks.user.create.mockResolvedValue(userStub.user1); - mocks.session.create.mockResolvedValue(sessionStub.valid); + mocks.session.create.mockResolvedValue(factory.session()); mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined }); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf( @@ -572,7 +602,7 @@ describe('AuthService', () => { it(`should use the mobile redirect override for a url of ${url}`, async () => { mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); mocks.user.getByOAuthId.mockResolvedValue(userStub.user1); - mocks.session.create.mockResolvedValue(sessionStub.valid); + mocks.session.create.mockResolvedValue(factory.session()); await sut.callback({ url }, loginDetails); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 235f20e705..4110427b0c 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -338,7 +338,9 @@ export class AuthService extends BaseService { return { user: session.user, - session, + session: { + id: session.id, + }, }; } diff --git a/server/src/services/session.service.spec.ts b/server/src/services/session.service.spec.ts index 3d1b09a39d..c3ab5619be 100644 --- a/server/src/services/session.service.spec.ts +++ b/server/src/services/session.service.spec.ts @@ -1,7 +1,7 @@ import { JobStatus } from 'src/enum'; import { SessionService } from 'src/services/session.service'; import { authStub } from 'test/fixtures/auth.stub'; -import { sessionStub } from 'test/fixtures/session.stub'; +import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe('SessionService', () => { @@ -45,40 +45,35 @@ describe('SessionService', () => { describe('getAll', () => { it('should get the devices', async () => { - mocks.session.getByUserId.mockResolvedValue([sessionStub.valid as any, sessionStub.inactive]); - await expect(sut.getAll(authStub.user1)).resolves.toEqual([ - { - createdAt: '2021-01-01T00:00:00.000Z', - current: true, - deviceOS: '', - deviceType: '', - id: 'token-id', - updatedAt: expect.any(String), - }, - { - createdAt: '2021-01-01T00:00:00.000Z', - current: false, - deviceOS: 'Android', - deviceType: 'Mobile', - id: 'not_active', - updatedAt: expect.any(String), - }, + const currentSession = factory.session(); + const otherSession = factory.session(); + const auth = factory.auth({ session: currentSession }); + + mocks.session.getByUserId.mockResolvedValue([currentSession, otherSession]); + + await expect(sut.getAll(auth)).resolves.toEqual([ + expect.objectContaining({ current: true, id: currentSession.id }), + expect.objectContaining({ current: false, id: otherSession.id }), ]); - expect(mocks.session.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id); + expect(mocks.session.getByUserId).toHaveBeenCalledWith(auth.user.id); }); }); describe('logoutDevices', () => { it('should logout all devices', async () => { - mocks.session.getByUserId.mockResolvedValue([sessionStub.inactive, sessionStub.valid] as any[]); + const currentSession = factory.session(); + const otherSession = factory.session(); + const auth = factory.auth({ session: currentSession }); + + mocks.session.getByUserId.mockResolvedValue([currentSession, otherSession]); mocks.session.delete.mockResolvedValue(); - await sut.deleteAll(authStub.user1); + await sut.deleteAll(auth); - expect(mocks.session.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id); - expect(mocks.session.delete).toHaveBeenCalledWith('not_active'); - expect(mocks.session.delete).not.toHaveBeenCalledWith('token-id'); + expect(mocks.session.getByUserId).toHaveBeenCalledWith(auth.user.id); + expect(mocks.session.delete).toHaveBeenCalledWith(otherSession.id); + expect(mocks.session.delete).not.toHaveBeenCalledWith(currentSession.id); }); }); diff --git a/server/src/types.ts b/server/src/types.ts index 3e74a39730..68207c4b9e 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -13,20 +13,11 @@ import { TranscodeTarget, VideoCodec, } from 'src/enum'; -import { SearchRepository } from 'src/repositories/search.repository'; -import { SessionRepository } from 'src/repositories/session.repository'; export type DeepPartial = T extends object ? { [K in keyof T]?: DeepPartial } : T; export type RepositoryInterface = Pick; -type ISearchRepository = RepositoryInterface; -type ISessionRepository = RepositoryInterface; - -export type SearchPlacesItem = Awaited>[0]; - -export type SessionItem = Awaited>[0]; - export interface CropOptions { top: number; left: number; diff --git a/server/test/fixtures/auth.stub.ts b/server/test/fixtures/auth.stub.ts index 4201334b41..f5fbe07b53 100644 --- a/server/test/fixtures/auth.stub.ts +++ b/server/test/fixtures/auth.stub.ts @@ -1,6 +1,6 @@ +import { Session } from 'src/database'; import { AuthDto } from 'src/dtos/auth.dto'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; -import { SessionItem } from 'src/types'; const authUser = { admin: { @@ -27,7 +27,7 @@ export const authStub = { user: authUser.user1, session: { id: 'token-id', - } as SessionItem, + } as Session, }), user2: Object.freeze({ user: { @@ -40,7 +40,7 @@ export const authStub = { }, session: { id: 'token-id', - } as SessionItem, + } as Session, }), adminSharedLink: Object.freeze({ user: authUser.admin, diff --git a/server/test/fixtures/session.stub.ts b/server/test/fixtures/session.stub.ts deleted file mode 100644 index 93eac28c57..0000000000 --- a/server/test/fixtures/session.stub.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { SessionItem } from 'src/types'; -import { userStub } from 'test/fixtures/user.stub'; - -export const sessionStub = { - valid: Object.freeze({ - id: 'token-id', - token: 'auth_token', - userId: userStub.user1.id, - user: userStub.user1, - createdAt: new Date('2021-01-01'), - updatedAt: new Date(), - deviceType: '', - deviceOS: '', - updateId: 'uuid-v7', - }), - inactive: Object.freeze({ - id: 'not_active', - token: 'auth_token', - userId: userStub.user1.id, - user: userStub.user1, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - deviceType: 'Mobile', - deviceOS: 'Android', - updateId: 'uuid-v7', - }), -}; diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index d2a7ba6e8f..35984cabbd 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -8,6 +8,7 @@ import { Library, Memory, Partner, + Session, SidecarWriteAsset, User, UserAdmin, @@ -31,7 +32,11 @@ export const newEmbedding = () => { return '[' + embedding + ']'; }; -const authFactory = ({ apiKey, ...user }: Partial & { apiKey?: Partial } = {}) => { +const authFactory = ({ + apiKey, + session, + ...user +}: Partial & { apiKey?: Partial; session?: { id: string } } = {}) => { const auth: AuthDto = { user: authUserFactory(user), }; @@ -40,6 +45,10 @@ const authFactory = ({ apiKey, ...user }: Partial & { apiKey?: Partial auth.apiKey = authApiKeyFactory(apiKey); } + if (session) { + auth.session = { id: session.id }; + } + return auth; }; @@ -76,7 +85,7 @@ const partnerFactory = (partner: Partial = {}) => { }; }; -const sessionFactory = () => ({ +const sessionFactory = (session: Partial = {}) => ({ id: newUuid(), createdAt: newDate(), updatedAt: newDate(), @@ -85,6 +94,7 @@ const sessionFactory = () => ({ deviceType: 'mobile', token: 'abc123', userId: newUuid(), + ...session, }); const stackFactory = () => ({