mirror of
https://github.com/immich-app/immich.git
synced 2026-07-21 21:34:17 +03:00
Compare commits
1 Commits
feat/workf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73329a8ce8 |
@@ -308,76 +308,6 @@
|
||||
},
|
||||
"uiHints": ["Filter"]
|
||||
},
|
||||
{
|
||||
"name": "assetExifFilter",
|
||||
"title": "Filter by EXIF metadata",
|
||||
"description": "Filter assets by their EXIF properties",
|
||||
"types": ["AssetV1"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"property": {
|
||||
"title": "Property",
|
||||
"description": "EXIF property to match",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"make",
|
||||
"model",
|
||||
"exifImageWidth",
|
||||
"exifImageHeight",
|
||||
"fileSizeInByte",
|
||||
"orientation",
|
||||
"lensModel",
|
||||
"fNumber",
|
||||
"focalLength",
|
||||
"iso",
|
||||
"description",
|
||||
"fps",
|
||||
"exposureTime",
|
||||
"livePhotoCID",
|
||||
"timeZone",
|
||||
"projectionType",
|
||||
"profileDescription",
|
||||
"colorspace",
|
||||
"bitsPerSample",
|
||||
"rating"
|
||||
],
|
||||
"uiHint": {
|
||||
"order": 1
|
||||
}
|
||||
},
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"title": "Pattern",
|
||||
"description": "Text or regex pattern to match against property value",
|
||||
"uiHint": {
|
||||
"order": 2
|
||||
}
|
||||
},
|
||||
"matchType": {
|
||||
"type": "string",
|
||||
"title": "Match type",
|
||||
"enum": ["contains", "startsWith", "exact", "regex"],
|
||||
"default": "contains",
|
||||
"description": "Type of pattern matching to perform",
|
||||
"uiHint": {
|
||||
"order": 3
|
||||
}
|
||||
},
|
||||
"caseSensitive": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"title": "Case sensitive",
|
||||
"description": "Whether matching should be case-sensitive",
|
||||
"uiHint": {
|
||||
"order": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["property", "pattern"]
|
||||
},
|
||||
"uiHints": ["Filter"]
|
||||
},
|
||||
{
|
||||
"name": "assetArchive",
|
||||
"title": "Archive asset",
|
||||
|
||||
@@ -2,42 +2,6 @@ import { wrapper } from '@immich/plugin-sdk';
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
import type { Manifest } from '../dist/index.d.ts';
|
||||
|
||||
type MatchValueConfig = {
|
||||
pattern: string;
|
||||
matchType?: 'contains' | 'exact' | 'regex' | 'startsWith';
|
||||
caseSensitive?: boolean;
|
||||
};
|
||||
|
||||
const matchValueResult = (value: string, config: MatchValueConfig) => {
|
||||
const { pattern, matchType = 'contains', caseSensitive = false } = config;
|
||||
const searchName = caseSensitive ? value : value.toLowerCase();
|
||||
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
|
||||
|
||||
switch (matchType) {
|
||||
case 'contains': {
|
||||
return { workflow: { continue: searchName.includes(searchPattern) } };
|
||||
}
|
||||
|
||||
case 'exact': {
|
||||
return { workflow: { continue: searchName === searchPattern } };
|
||||
}
|
||||
|
||||
case 'startsWith': {
|
||||
return { workflow: { continue: searchName.startsWith(searchPattern) } };
|
||||
}
|
||||
|
||||
case 'regex': {
|
||||
const flags = caseSensitive ? '' : 'i';
|
||||
const regex = new RegExp(searchPattern, flags);
|
||||
return { workflow: { continue: regex.test(value) } };
|
||||
}
|
||||
|
||||
default: {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const methods = wrapper<Manifest>({
|
||||
assetAddToAlbums: ({ config, data, functions }) => {
|
||||
const assetId = data.asset.id;
|
||||
@@ -89,7 +53,39 @@ const methods = wrapper<Manifest>({
|
||||
}
|
||||
},
|
||||
|
||||
assetFileFilter: ({ data, config }) => matchValueResult(data.asset.originalFileName || '', config),
|
||||
assetFileFilter: ({ data, config }) => {
|
||||
const { pattern, matchType = 'contains', caseSensitive = false, usePath = false } = config;
|
||||
|
||||
const { asset } = data;
|
||||
|
||||
const fileName = usePath ? asset.originalPath : asset.originalFileName;
|
||||
const searchName = caseSensitive ? fileName : fileName.toLowerCase();
|
||||
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
|
||||
|
||||
switch (matchType) {
|
||||
case 'contains': {
|
||||
return { workflow: { continue: searchName.includes(searchPattern) } };
|
||||
}
|
||||
|
||||
case 'exact': {
|
||||
return { workflow: { continue: searchName === searchPattern } };
|
||||
}
|
||||
|
||||
case 'startsWith': {
|
||||
return { workflow: { continue: searchName.startsWith(searchPattern) } };
|
||||
}
|
||||
|
||||
case 'regex': {
|
||||
const flags = caseSensitive ? '' : 'i';
|
||||
const regex = new RegExp(searchPattern, flags);
|
||||
return { workflow: { continue: regex.test(fileName) } };
|
||||
}
|
||||
|
||||
default: {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
assetLocationFilter: ({ config, data }) => {
|
||||
if (
|
||||
@@ -128,14 +124,6 @@ const methods = wrapper<Manifest>({
|
||||
return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } };
|
||||
},
|
||||
|
||||
assetExifFilter: ({ config, data }) => {
|
||||
if (!data.asset.exifInfo || data.asset.exifInfo[config.property] === null) {
|
||||
return { workflow: { continue: false } };
|
||||
}
|
||||
|
||||
return matchValueResult(String(data.asset.exifInfo[config.property]), config);
|
||||
},
|
||||
|
||||
assetDateFilter: ({ config, data }) => {
|
||||
const assetDate = new Date(data.asset.localDateTime);
|
||||
let startDate = new Date(config.startDate.year, config.startDate.month - 1, config.startDate.day);
|
||||
@@ -212,7 +200,6 @@ const {
|
||||
assetFavorite,
|
||||
assetFileFilter,
|
||||
assetLocationFilter,
|
||||
assetExifFilter,
|
||||
assetDateFilter,
|
||||
assetLock,
|
||||
assetMissingTimeZoneFilter,
|
||||
@@ -230,7 +217,6 @@ export {
|
||||
assetFavorite,
|
||||
assetFileFilter,
|
||||
assetLocationFilter,
|
||||
assetExifFilter,
|
||||
assetDateFilter,
|
||||
assetLock,
|
||||
assetMissingTimeZoneFilter,
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
select
|
||||
"id",
|
||||
"expiresAt",
|
||||
"pinExpiresAt"
|
||||
"pinExpiresAt",
|
||||
"oauthBearerToken"
|
||||
from
|
||||
"session"
|
||||
where
|
||||
|
||||
@@ -83,7 +83,7 @@ export class OAuthRepository {
|
||||
url: string,
|
||||
expectedState: string,
|
||||
codeVerifier: string,
|
||||
): Promise<{ profile: OAuthProfile; sid?: string }> {
|
||||
): Promise<{ profile: OAuthProfile; sid?: string; idToken?: string }> {
|
||||
const client = await this.getClient(config);
|
||||
const pkceCodeVerifier = client.serverMetadata().supportsPKCE() ? codeVerifier : undefined;
|
||||
|
||||
@@ -111,7 +111,7 @@ export class OAuthRepository {
|
||||
}
|
||||
}
|
||||
|
||||
return { profile, sid };
|
||||
return { profile, sid, idToken: tokens.id_token };
|
||||
} catch (error: Error | any) {
|
||||
if (error.message.includes('unexpected JWT alg received')) {
|
||||
this.logger.warn(
|
||||
|
||||
@@ -32,7 +32,7 @@ export class SessionRepository {
|
||||
get(id: string) {
|
||||
return this.db
|
||||
.selectFrom('session')
|
||||
.select(['id', 'expiresAt', 'pinExpiresAt'])
|
||||
.select(['id', 'expiresAt', 'pinExpiresAt', 'oauthBearerToken'])
|
||||
.where('id', '=', id)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "session" ADD "oauthBearerToken" character varying;`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "session" DROP COLUMN "oauthBearerToken";`.execute(db);
|
||||
}
|
||||
@@ -55,4 +55,7 @@ export class SessionTable {
|
||||
|
||||
@Column({ nullable: true, index: true })
|
||||
oauthSid!: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
oauthBearerToken!: string | null;
|
||||
}
|
||||
|
||||
@@ -160,7 +160,25 @@ describe(AuthService.name, () => {
|
||||
|
||||
await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({
|
||||
successful: true,
|
||||
redirectUri: 'http://end-session-endpoint',
|
||||
redirectUri: 'http://end-session-endpoint/',
|
||||
});
|
||||
});
|
||||
|
||||
it('should include the id token hint for OAuth sessions', async () => {
|
||||
const auth = AuthFactory.from().session().build();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.session.get.mockResolvedValue({
|
||||
id: auth.session!.id,
|
||||
expiresAt: null,
|
||||
oauthBearerToken: 'id-token',
|
||||
pinExpiresAt: null,
|
||||
});
|
||||
mocks.session.delete.mockResolvedValue();
|
||||
|
||||
await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({
|
||||
successful: true,
|
||||
redirectUri: 'http://end-session-endpoint/?id_token_hint=id-token',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,7 +191,7 @@ describe(AuthService.name, () => {
|
||||
|
||||
await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({
|
||||
successful: true,
|
||||
redirectUri: 'http://custom-logout-url',
|
||||
redirectUri: 'http://custom-logout-url/',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -186,7 +204,7 @@ describe(AuthService.name, () => {
|
||||
|
||||
await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({
|
||||
successful: true,
|
||||
redirectUri: 'http://end-session-endpoint',
|
||||
redirectUri: 'http://end-session-endpoint/',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -201,6 +219,12 @@ describe(AuthService.name, () => {
|
||||
|
||||
it('should delete the access token', async () => {
|
||||
const auth = { user: { id: '123' }, session: { id: 'token123' } } as AuthDto;
|
||||
mocks.session.get.mockResolvedValue({
|
||||
id: auth.session!.id,
|
||||
expiresAt: null,
|
||||
oauthBearerToken: null,
|
||||
pinExpiresAt: null,
|
||||
});
|
||||
mocks.session.delete.mockResolvedValue();
|
||||
|
||||
await expect(sut.logout(auth, AuthType.Password)).resolves.toEqual({
|
||||
@@ -722,6 +746,27 @@ describe(AuthService.name, () => {
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub });
|
||||
});
|
||||
|
||||
it('should store the OAuth bearer token on the new session', async () => {
|
||||
const user = UserFactory.create();
|
||||
const profile = OAuthProfileFactory.create();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile, sid: 'oauth-sid', idToken: 'oauth-bearer-token' });
|
||||
mocks.user.getByEmail.mockResolvedValue(user);
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.session.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ oauthSid: 'oauth-sid', oauthBearerToken: 'oauth-bearer-token' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should normalize the email from the OAuth profile before linking', async () => {
|
||||
const user = UserFactory.create();
|
||||
const profile = OAuthProfileFactory.create({ email: ' TEST@IMMICH.CLOUD ' });
|
||||
@@ -1125,6 +1170,7 @@ describe(AuthService.name, () => {
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||
profile: { sub: 'sub' },
|
||||
sid: session.oauthSid ?? undefined,
|
||||
idToken: session.oauthBearerToken ?? undefined,
|
||||
});
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
mocks.session.update.mockResolvedValue(session);
|
||||
@@ -1135,7 +1181,10 @@ describe(AuthService.name, () => {
|
||||
{},
|
||||
);
|
||||
|
||||
expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: session.oauthSid });
|
||||
expect(mocks.session.update).toHaveBeenCalledWith(session.id, {
|
||||
oauthSid: session.oauthSid,
|
||||
oauthBearerToken: session.oauthBearerToken,
|
||||
});
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: 'sub' });
|
||||
});
|
||||
|
||||
@@ -1169,7 +1218,7 @@ describe(AuthService.name, () => {
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' });
|
||||
});
|
||||
|
||||
it('should unlink an account and remove the oauthSid from the session', async () => {
|
||||
it('should unlink an account and remove the OAuth data from the session', async () => {
|
||||
const user = UserFactory.create();
|
||||
const session = SessionFactory.create();
|
||||
const auth = AuthFactory.from(user).session(session).build();
|
||||
@@ -1180,7 +1229,7 @@ describe(AuthService.name, () => {
|
||||
|
||||
await sut.unlink(auth);
|
||||
|
||||
expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: null });
|
||||
expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: null, oauthBearerToken: null });
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,14 +76,17 @@ export class AuthService extends BaseService {
|
||||
}
|
||||
|
||||
async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> {
|
||||
let oauthBearerToken: string | undefined;
|
||||
if (auth.session) {
|
||||
const session = await this.sessionRepository.get(auth.session.id);
|
||||
oauthBearerToken = session?.oauthBearerToken ?? undefined;
|
||||
await this.sessionRepository.delete(auth.session.id);
|
||||
await this.eventRepository.emit('SessionDelete', { sessionId: auth.session.id });
|
||||
}
|
||||
|
||||
return {
|
||||
successful: true,
|
||||
redirectUri: await this.getLogoutEndpoint(authType),
|
||||
redirectUri: await this.getLogoutEndpoint(authType, oauthBearerToken),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -306,12 +309,11 @@ export class AuthService extends BaseService {
|
||||
}
|
||||
|
||||
const url = this.resolveRedirectUri(oauth, dto.url);
|
||||
const { profile, sid: oauthSid } = await this.oauthRepository.getProfileAndOAuthSid(
|
||||
oauth,
|
||||
url,
|
||||
expectedState,
|
||||
codeVerifier,
|
||||
);
|
||||
const {
|
||||
profile,
|
||||
sid: oauthSid,
|
||||
idToken: oauthBearerToken,
|
||||
} = await this.oauthRepository.getProfileAndOAuthSid(oauth, url, expectedState, codeVerifier);
|
||||
const normalizedEmail = profile.email ? profile.email.trim().toLowerCase() : undefined;
|
||||
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim, roleClaim } = oauth;
|
||||
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
|
||||
@@ -378,7 +380,7 @@ export class AuthService extends BaseService {
|
||||
await this.syncProfilePicture(user, profile.picture);
|
||||
}
|
||||
|
||||
return this.createLoginResponse(user, loginDetails, oauthSid);
|
||||
return this.createLoginResponse(user, loginDetails, oauthSid, oauthBearerToken);
|
||||
}
|
||||
|
||||
private async syncProfilePicture(user: UserAdmin, url: string) {
|
||||
@@ -419,6 +421,7 @@ export class AuthService extends BaseService {
|
||||
const {
|
||||
profile: { sub: oauthId },
|
||||
sid,
|
||||
idToken,
|
||||
} = await this.oauthRepository.getProfileAndOAuthSid(oauth, dto.url, expectedState, codeVerifier);
|
||||
const duplicate = await this.userRepository.getByOAuthId(oauthId);
|
||||
if (duplicate && duplicate.id !== auth.user.id) {
|
||||
@@ -426,8 +429,11 @@ export class AuthService extends BaseService {
|
||||
throw new BadRequestException('This OAuth account has already been linked to another user.');
|
||||
}
|
||||
|
||||
if (auth.session && sid) {
|
||||
await this.sessionRepository.update(auth.session.id, { oauthSid: sid });
|
||||
if (auth.session && (sid || idToken)) {
|
||||
await this.sessionRepository.update(auth.session.id, {
|
||||
oauthSid: sid,
|
||||
oauthBearerToken: idToken,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await this.userRepository.update(auth.user.id, { oauthId });
|
||||
@@ -436,14 +442,14 @@ export class AuthService extends BaseService {
|
||||
|
||||
async unlink(auth: AuthDto): Promise<UserAdminResponseDto> {
|
||||
if (auth.session) {
|
||||
await this.sessionRepository.update(auth.session.id, { oauthSid: null });
|
||||
await this.sessionRepository.update(auth.session.id, { oauthSid: null, oauthBearerToken: null });
|
||||
}
|
||||
|
||||
const user = await this.userRepository.update(auth.user.id, { oauthId: '' });
|
||||
return mapUserAdmin(user);
|
||||
}
|
||||
|
||||
private async getLogoutEndpoint(authType: AuthType): Promise<string> {
|
||||
private async getLogoutEndpoint(authType: AuthType, oauthBearerToken?: string | null): Promise<string> {
|
||||
if (authType !== AuthType.OAuth) {
|
||||
return LOGIN_URL;
|
||||
}
|
||||
@@ -453,11 +459,20 @@ export class AuthService extends BaseService {
|
||||
return LOGIN_URL;
|
||||
}
|
||||
|
||||
if (config.oauth.endSessionEndpoint) {
|
||||
return config.oauth.endSessionEndpoint;
|
||||
const endSessionEndpoint =
|
||||
config.oauth.endSessionEndpoint || (await this.oauthRepository.getLogoutEndpoint(config.oauth));
|
||||
|
||||
if (!endSessionEndpoint) {
|
||||
return LOGIN_URL;
|
||||
}
|
||||
|
||||
return (await this.oauthRepository.getLogoutEndpoint(config.oauth)) || LOGIN_URL;
|
||||
const url = new URL(endSessionEndpoint);
|
||||
|
||||
if (oauthBearerToken) {
|
||||
url.searchParams.set('id_token_hint', oauthBearerToken);
|
||||
}
|
||||
|
||||
return url.href;
|
||||
}
|
||||
|
||||
private getBearerToken(headers: IncomingHttpHeaders): string | null {
|
||||
@@ -599,7 +614,12 @@ export class AuthService extends BaseService {
|
||||
await this.sessionRepository.update(auth.session.id, { pinExpiresAt: null });
|
||||
}
|
||||
|
||||
private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails, oauthSid?: string) {
|
||||
private async createLoginResponse(
|
||||
user: UserAdmin,
|
||||
loginDetails: LoginDetails,
|
||||
oauthSid?: string,
|
||||
oauthBearerToken?: string,
|
||||
) {
|
||||
const token = this.cryptoRepository.randomBytesAsText(32);
|
||||
const hashed = this.cryptoRepository.hashSha256(token);
|
||||
|
||||
@@ -610,6 +630,7 @@ export class AuthService extends BaseService {
|
||||
appVersion: loginDetails.appVersion,
|
||||
userId: user.id,
|
||||
oauthSid: oauthSid ?? null,
|
||||
oauthBearerToken: oauthBearerToken ?? null,
|
||||
});
|
||||
|
||||
return mapLoginResponse(user, token);
|
||||
|
||||
@@ -26,6 +26,7 @@ export class SessionFactory {
|
||||
updatedAt: newDate(),
|
||||
userId: newUuid(),
|
||||
oauthSid: newUuid(),
|
||||
oauthBearerToken: 'oauth-bearer-token',
|
||||
...dto,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ const authFactory = ({
|
||||
user,
|
||||
}: {
|
||||
apiKey?: Partial<AuthApiKey>;
|
||||
session?: { id?: string; hasElevatedPermission?: boolean };
|
||||
session?: { id?: string; hasElevatedPermission?: boolean; oauthBearerToken?: string | null };
|
||||
user?: Omit<
|
||||
Partial<UserAdmin>,
|
||||
'createdAt' | 'updatedAt' | 'deletedAt' | 'fileCreatedAt' | 'fileModifiedAt' | 'localDateTime' | 'profileChangedAt'
|
||||
|
||||
Reference in New Issue
Block a user