mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat: add mobile device version tracking
This commit is contained in:
parent
76eaee3657
commit
6a359f0372
16 changed files with 236 additions and 13 deletions
|
|
@ -30,8 +30,27 @@ class UserAdminResponseDto {
|
|||
required this.status,
|
||||
required this.storageLabel,
|
||||
required this.updatedAt,
|
||||
required this.mobileDevices,
|
||||
});
|
||||
|
||||
List<MobileDeviceDto> mobileDevices;
|
||||
|
||||
}
|
||||
|
||||
class MobileDeviceDto {
|
||||
MobileDeviceDto({
|
||||
required this.deviceType,
|
||||
required this.deviceOS,
|
||||
required this.appVersion,
|
||||
required this.lastSeen,
|
||||
});
|
||||
|
||||
String deviceType;
|
||||
String deviceOS;
|
||||
String appVersion;
|
||||
DateTime lastSeen;
|
||||
}
|
||||
|
||||
UserAvatarColor avatarColor;
|
||||
|
||||
DateTime createdAt;
|
||||
|
|
|
|||
|
|
@ -12348,6 +12348,30 @@
|
|||
},
|
||||
"type": "object"
|
||||
},
|
||||
"MobileDeviceDto": {
|
||||
"properties": {
|
||||
"appVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
"deviceOS": {
|
||||
"type": "string"
|
||||
},
|
||||
"deviceType": {
|
||||
"type": "string"
|
||||
},
|
||||
"lastSeen": {
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"appVersion",
|
||||
"deviceOS",
|
||||
"deviceType",
|
||||
"lastSeen"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"NotificationCreateDto": {
|
||||
"properties": {
|
||||
"data": {
|
||||
|
|
@ -17067,6 +17091,12 @@
|
|||
],
|
||||
"nullable": true
|
||||
},
|
||||
"mobileDevices": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MobileDeviceDto"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -17117,6 +17147,7 @@
|
|||
"id",
|
||||
"isAdmin",
|
||||
"license",
|
||||
"mobileDevices",
|
||||
"name",
|
||||
"oauthId",
|
||||
"profileChangedAt",
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ export type UserAdmin = User & {
|
|||
quotaUsageInBytes: number;
|
||||
status: UserStatus;
|
||||
metadata: UserMetadataItem[];
|
||||
sessions?: Session[];
|
||||
};
|
||||
|
||||
export type StorageAsset = {
|
||||
|
|
@ -238,6 +239,7 @@ export type Session = {
|
|||
expiresAt: Date | null;
|
||||
deviceOS: string;
|
||||
deviceType: string;
|
||||
appVersion: string;
|
||||
pinExpiresAt: Date | null;
|
||||
isPendingSyncReset: boolean;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,6 +6,20 @@ import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
|
|||
import { UserMetadataItem } from 'src/types';
|
||||
import { Optional, PinCode, ValidateBoolean, ValidateEnum, ValidateUUID, toEmail, toSanitized } from 'src/validation';
|
||||
|
||||
export class MobileDeviceDto {
|
||||
@ApiProperty()
|
||||
deviceType!: string;
|
||||
|
||||
@ApiProperty()
|
||||
deviceOS!: string;
|
||||
|
||||
@ApiProperty()
|
||||
appVersion!: string;
|
||||
|
||||
@ApiProperty()
|
||||
lastSeen!: Date;
|
||||
}
|
||||
|
||||
export class UserUpdateMeDto {
|
||||
@Optional()
|
||||
@IsEmail({ require_tld: false })
|
||||
|
|
@ -152,6 +166,8 @@ export class UserAdminDeleteDto {
|
|||
}
|
||||
|
||||
export class UserAdminResponseDto extends UserResponseDto {
|
||||
@ApiProperty({ type: () => [MobileDeviceDto] })
|
||||
mobileDevices!: MobileDeviceDto[];
|
||||
storageLabel!: string | null;
|
||||
shouldChangePassword!: boolean;
|
||||
isAdmin!: boolean;
|
||||
|
|
@ -173,6 +189,15 @@ export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto {
|
|||
const license = metadata.find(
|
||||
(item): item is UserMetadataItem<UserMetadataKey.License> => item.key === UserMetadataKey.License,
|
||||
)?.value;
|
||||
const mobileDevices = (entity.sessions || [])
|
||||
.filter((s) => s.appVersion && s.appVersion !== '')
|
||||
.map((s) => ({
|
||||
deviceType: s.deviceType,
|
||||
deviceOS: s.deviceOS,
|
||||
appVersion: s.appVersion,
|
||||
lastSeen: s.updatedAt,
|
||||
}));
|
||||
|
||||
return {
|
||||
...mapUser(entity),
|
||||
storageLabel: entity.storageLabel,
|
||||
|
|
@ -186,5 +211,6 @@ export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto {
|
|||
quotaUsageInBytes: entity.quotaUsageInBytes,
|
||||
status: entity.status,
|
||||
license: license ? { ...license, activatedAt: new Date(license?.activatedAt) } : null,
|
||||
mobileDevices,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,13 +56,21 @@ export const FileResponse = () =>
|
|||
|
||||
export const GetLoginDetails = createParamDecorator((data, context: ExecutionContext): LoginDetails => {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const userAgent = UAParser(request.headers['user-agent']);
|
||||
const userAgentString = request.headers['user-agent'] as string || '';
|
||||
const userAgent = UAParser(userAgentString);
|
||||
|
||||
let appVersion = '';
|
||||
const immichMatch = userAgentString.match(/^Immich_(Android|iOS)_(.+)$/);
|
||||
if (immichMatch) {
|
||||
appVersion = immichMatch[2];
|
||||
}
|
||||
|
||||
return {
|
||||
clientIp: request.ip ?? '',
|
||||
isSecure: request.secure,
|
||||
deviceType: userAgent.browser.name || userAgent.device.type || (request.headers.devicemodel as string) || '',
|
||||
deviceOS: userAgent.os.name || (request.headers.devicetype as string) || '',
|
||||
appVersion,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -86,7 +94,6 @@ export class AuthGuard implements CanActivate {
|
|||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const targets = [context.getHandler()];
|
||||
|
||||
const options = this.reflector.getAllAndOverride<AuthenticatedOptions | undefined>(MetadataKey.AuthRoute, targets);
|
||||
if (!options) {
|
||||
return true;
|
||||
|
|
@ -105,6 +112,24 @@ export class AuthGuard implements CanActivate {
|
|||
metadata: { adminRoute, sharedLinkRoute, permission, uri: request.path },
|
||||
});
|
||||
|
||||
if (request.user?.session) {
|
||||
let appVersion = '';
|
||||
const userAgent = request.headers['user-agent'] as string || '';
|
||||
const immichMatch = userAgent.match(/^Immich_(Android|iOS)_(.+)$/);
|
||||
if (immichMatch) {
|
||||
appVersion = immichMatch[2];
|
||||
}
|
||||
if (request.headers['x-app-version']) {
|
||||
appVersion = request.headers['x-app-version'] as string;
|
||||
}
|
||||
if (appVersion) {
|
||||
await this.authService.heartbeat(request.user, appVersion);
|
||||
} else {
|
||||
// Always update lastSeen even if appVersion is missing
|
||||
await this.authService.heartbeat(request.user, '');
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,20 @@ export type SessionSearchOptions = { updatedBefore: Date };
|
|||
|
||||
@Injectable()
|
||||
export class SessionRepository {
|
||||
|
||||
async cleanupMobileStaleSessions() {
|
||||
const deleted = await this.db
|
||||
.deleteFrom('session')
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
eb('updatedAt', '<=', DateTime.now().minus({ minutes: 1 }).toJSDate()),
|
||||
eb('deviceOS', 'in', ['Android', 'iOS']),
|
||||
])
|
||||
)
|
||||
.returning(['id', 'deviceOS', 'deviceType'])
|
||||
.execute();
|
||||
return deleted;
|
||||
}
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
cleanup() {
|
||||
|
|
|
|||
|
|
@ -69,14 +69,22 @@ export class UserRepository {
|
|||
}
|
||||
|
||||
@GenerateSql()
|
||||
getAdmin() {
|
||||
return this.db
|
||||
async getAdmin() {
|
||||
const user = await this.db
|
||||
.selectFrom('user')
|
||||
.select(columns.userAdmin)
|
||||
.select(withMetadata)
|
||||
.where('user.isAdmin', '=', true)
|
||||
.where('user.deletedAt', 'is', null)
|
||||
.executeTakeFirst();
|
||||
if (user) {
|
||||
(user as any).sessions = await this.db
|
||||
.selectFrom('session')
|
||||
.selectAll()
|
||||
.where('session.userId', '=', user.id)
|
||||
.execute();
|
||||
}
|
||||
return user as any;
|
||||
}
|
||||
|
||||
@GenerateSql()
|
||||
|
|
@ -163,8 +171,8 @@ export class UserRepository {
|
|||
{ name: 'with deleted', params: [{ withDeleted: true }] },
|
||||
{ name: 'without deleted', params: [{ withDeleted: false }] },
|
||||
)
|
||||
getList({ id, withDeleted }: UserListFilter = {}) {
|
||||
return this.db
|
||||
async getList({ id, withDeleted }: UserListFilter = {}) {
|
||||
const users = await this.db
|
||||
.selectFrom('user')
|
||||
.select(columns.userAdmin)
|
||||
.select(withMetadata)
|
||||
|
|
@ -172,6 +180,14 @@ export class UserRepository {
|
|||
.$if(!!id, (eb) => eb.where('user.id', '=', id!))
|
||||
.orderBy('createdAt', 'desc')
|
||||
.execute();
|
||||
for (const user of users) {
|
||||
(user as any).sessions = await this.db
|
||||
.selectFrom('session')
|
||||
.selectAll()
|
||||
.where('session.userId', '=', user.id)
|
||||
.execute();
|
||||
}
|
||||
return users as any;
|
||||
}
|
||||
|
||||
async create(dto: Insertable<UserTable>) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
import { Kysely } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('session')
|
||||
.addColumn('appVersion', 'varchar(32)', (col) => col.defaultTo('').notNull())
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('session')
|
||||
.dropColumn('appVersion')
|
||||
.execute();
|
||||
}
|
||||
|
|
@ -42,6 +42,9 @@ export class SessionTable {
|
|||
@Column({ default: '' })
|
||||
deviceOS!: Generated<string>;
|
||||
|
||||
@Column({ default: '' })
|
||||
appVersion!: Generated<string>;
|
||||
|
||||
@UpdateIdColumn({ index: true })
|
||||
updateId!: Generated<string>;
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export interface LoginDetails {
|
|||
clientIp: string;
|
||||
deviceType: string;
|
||||
deviceOS: string;
|
||||
appVersion?: string;
|
||||
}
|
||||
|
||||
interface ClaimOptions<T> {
|
||||
|
|
@ -56,6 +57,23 @@ export type ValidateRequest = {
|
|||
|
||||
@Injectable()
|
||||
export class AuthService extends BaseService {
|
||||
/**
|
||||
* Cleanup mobile sessions not seen for over a week
|
||||
*/
|
||||
async cleanupStaleMobileSessions(): Promise<number> {
|
||||
const deleted = await this.sessionRepository.cleanupMobileStaleSessions();
|
||||
return deleted.length ?? 0;
|
||||
}
|
||||
|
||||
async heartbeat(auth: AuthDto, appVersion: string): Promise<void> {
|
||||
if (!auth.session) {
|
||||
throw new BadRequestException('No active session');
|
||||
}
|
||||
await this.sessionRepository.update(auth.session.id, {
|
||||
appVersion,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
async login(dto: LoginCredentialDto, details: LoginDetails) {
|
||||
const config = await this.getConfig({ withCache: false });
|
||||
if (!config.passwordLogin.enabled) {
|
||||
|
|
@ -529,6 +547,7 @@ export class AuthService extends BaseService {
|
|||
token: tokenHashed,
|
||||
deviceOS: loginDetails.deviceOS,
|
||||
deviceType: loginDetails.deviceType,
|
||||
appVersion: loginDetails.appVersion || '',
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { BaseService } from 'src/services/base.service';
|
|||
export class CliService extends BaseService {
|
||||
async listUsers(): Promise<UserAdminResponseDto[]> {
|
||||
const users = await this.userRepository.getList({ withDeleted: true });
|
||||
return users.map((user) => mapUserAdmin(user));
|
||||
return users.map((user:any) => mapUserAdmin(user));
|
||||
}
|
||||
|
||||
async resetAdminPassword(ask: (admin: UserAdminResponseDto) => Promise<string | undefined>) {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export class MemoryService extends BaseService {
|
|||
async onMemoriesCreate() {
|
||||
const users = await this.userRepository.getList({ withDeleted: false });
|
||||
const usersIds = await Promise.all(
|
||||
users.map((user) =>
|
||||
users.map((user:any) =>
|
||||
getMyPartnerIds({
|
||||
userId: user.id,
|
||||
repository: this.partnerRepository,
|
||||
|
|
@ -38,7 +38,7 @@ export class MemoryService extends BaseService {
|
|||
}
|
||||
|
||||
try {
|
||||
await Promise.all(users.map((owner, i) => this.createOnThisDayMemories(owner.id, usersIds[i], target)));
|
||||
await Promise.all(users.map((owner:any, i:any) => this.createOnThisDayMemories(owner.id, usersIds[i], target)));
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create memories for ${target.toISO()}`, error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ export class StorageTemplateService extends BaseService {
|
|||
const users = await this.userRepository.getList();
|
||||
|
||||
for await (const asset of assets) {
|
||||
const user = users.find((user) => user.id === asset.ownerId);
|
||||
const user = users.find((user:any) => user.id === asset.ownerId);
|
||||
const storageLabel = user?.storageLabel || null;
|
||||
const filename = asset.originalFileName || asset.id;
|
||||
await this.moveAsset(asset, { storageLabel, filename });
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export class UserAdminService extends BaseService {
|
|||
id: dto.id,
|
||||
withDeleted: dto.withDeleted,
|
||||
});
|
||||
return users.map((user) => mapUserAdmin(user));
|
||||
return users.map((user:any) => mapUserAdmin(user));
|
||||
}
|
||||
|
||||
async create(dto: UserAdminCreateDto): Promise<UserAdminResponseDto> {
|
||||
|
|
|
|||
|
|
@ -31,10 +31,11 @@ export class UserService extends BaseService {
|
|||
users = authUser ? [authUser] : [];
|
||||
}
|
||||
|
||||
return users.map((user) => mapUser(user));
|
||||
return users.map((user:any) => mapUser(user));
|
||||
}
|
||||
|
||||
async getMe(auth: AuthDto): Promise<UserAdminResponseDto> {
|
||||
await this.sessionRepository.cleanupMobileStaleSessions();
|
||||
const user = await this.userRepository.get(auth.user.id, {});
|
||||
if (!user) {
|
||||
throw new BadRequestException('User not found');
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
import UserRestoreConfirmModal from '$lib/modals/UserRestoreConfirmModal.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { user as authUser } from '$lib/stores/user.store';
|
||||
import { websocketStore } from '$lib/stores/websocket';
|
||||
import { createDateFormatter, findLocale } from '$lib/utils';
|
||||
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
|
|
@ -36,12 +37,16 @@
|
|||
} from '@immich/ui';
|
||||
import {
|
||||
mdiAccountOutline,
|
||||
mdiAndroid,
|
||||
mdiApple,
|
||||
mdiAppsBox,
|
||||
mdiCameraIris,
|
||||
mdiChartPie,
|
||||
mdiChartPieOutline,
|
||||
mdiCheckCircle,
|
||||
mdiDeleteRestore,
|
||||
mdiFeatureSearchOutline,
|
||||
mdiHelp,
|
||||
mdiLockSmart,
|
||||
mdiOnepassword,
|
||||
mdiPencilOutline,
|
||||
|
|
@ -64,7 +69,10 @@
|
|||
const TiB = 1024 ** 4;
|
||||
const usage = $derived(user.quotaUsageInBytes ?? 0);
|
||||
let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(usage, usage > TiB ? 2 : 0));
|
||||
|
||||
const { serverVersion } = websocketStore;
|
||||
let formattedServerVersion = $derived(
|
||||
$serverVersion ? `${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null,
|
||||
);
|
||||
const usedBytes = $derived(user.quotaUsageInBytes ?? 0);
|
||||
const availableBytes = $derived(user.quotaSizeInBytes ?? 1);
|
||||
let usedPercentage = $derived(Math.min(Math.round((usedBytes / availableBytes) * 100), 100));
|
||||
|
|
@ -355,6 +363,50 @@
|
|||
{/if}
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card color="secondary">
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2 px-4 py-2 text-primary">
|
||||
<Icon icon={mdiAppsBox} size="1.5rem" />
|
||||
<CardTitle>Mobile Devices</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div class="px-4 pb-7">
|
||||
<Stack gap={3}>
|
||||
{#if user.mobileDevices && user.mobileDevices.length > 0}
|
||||
{#each user.mobileDevices as device}
|
||||
<div
|
||||
class="flex items-center gap-4 p-3 rounded-lg bg-gray-50 dark:bg-immich-dark-primary/10 shadow-sm"
|
||||
>
|
||||
<span class="flex items-center justify-center">
|
||||
{#if device.deviceType === 'Android' || device.deviceOS === 'Android'}
|
||||
<Icon icon={mdiAndroid} size="2rem" class="text-green-600" />
|
||||
{:else if device.deviceType === 'iOS' || device.deviceType === 'Apple' || device.deviceType === 'iPhone' || device.deviceType === 'iPad' || device.deviceOS === 'iOS' || device.deviceOS === 'Apple' || device.deviceOS === 'iPhone' || device.deviceOS === 'iPad' || device.deviceOS === 'macOS'}
|
||||
<Icon icon={mdiApple} size="2rem" class="text-gray-700" />
|
||||
{:else}
|
||||
<Icon icon={mdiHelp} size="2rem" class="text-gray-400" />
|
||||
{/if}
|
||||
</span>
|
||||
<div class="flex flex-col flex-1">
|
||||
<span class="font-medium text-base"
|
||||
>{device.deviceType} <span class="mx-1">•</span>
|
||||
<span style="color: {device.appVersion < formattedServerVersion ? 'red' : 'inherit'}">
|
||||
v{device.appVersion}
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-xs text-gray-500"
|
||||
>Last seen: {new Date(device.lastSeen).toLocaleString()}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="text-gray-400">No mobile devices</span>
|
||||
{/if}
|
||||
</Stack>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue