Files
plezy/lib/profiles/profile.dart
T
2026-05-01 01:20:36 +02:00

240 lines
8.0 KiB
Dart

import 'dart:convert';
import 'package:crypto/crypto.dart';
import '../models/plex/plex_home_user.dart';
/// Top-level profile — the user-facing identity in the app.
///
/// Two kinds:
/// - [ProfileKind.local]: a Plezy-only profile created by the user. May have
/// an optional 4-digit PIN.
/// - [ProfileKind.plexHome]: auto-surfaced from a connected Plex account's
/// Home users. PIN protection is handled server-side by Plex via the
/// `/home/users/{uuid}/switch` flow — `pinHash` is unused.
///
/// A profile owns 1+ connections via the `profile_connections` join table.
/// The join row carries the per-profile user-level token used to talk to
/// each connection.
class Profile {
final String id;
final ProfileKind kind;
final String displayName;
final String? avatarThumbUrl;
/// Hashed PIN if set — only meaningful for [ProfileKind.local]. The raw
/// PIN is never persisted; see [computePinHash].
final String? pinHash;
/// For [ProfileKind.plexHome]: the parent Plex account's connection id.
/// `null` for local profiles.
final String? parentConnectionId;
/// For [ProfileKind.plexHome]: the Plex Home user UUID. Used by the
/// active-profile binder to call `/home/users/{uuid}/switch`. `null` for
/// local profiles.
final String? plexHomeUserUuid;
/// Plex Home flags — only meaningful for [ProfileKind.plexHome].
final bool plexRestricted;
final bool plexAdmin;
/// Plex's `protected` flag — true when the home user has a PIN that must
/// be entered before `/home/users/{uuid}/switch` will succeed.
final bool plexProtected;
final int sortOrder;
final DateTime createdAt;
final DateTime? lastUsedAt;
Profile({
required this.id,
required this.kind,
required this.displayName,
this.avatarThumbUrl,
this.pinHash,
this.parentConnectionId,
this.plexHomeUserUuid,
this.plexRestricted = false,
this.plexAdmin = false,
this.plexProtected = false,
this.sortOrder = 0,
required this.createdAt,
this.lastUsedAt,
});
/// Construct an in-memory virtual `Profile` for a Plex Home user. These
/// are never persisted — Plex owns the Home user list, so the picker
/// reads them live from [PlexHomeService] and merges them with the local
/// rows from [ProfileRegistry].
factory Profile.virtualPlexHome({
required String connectionId,
required PlexHomeUser homeUser,
DateTime? lastUsedAt,
}) {
return Profile(
id: plexHomeProfileId(accountConnectionId: connectionId, homeUserUuid: homeUser.uuid),
kind: ProfileKind.plexHome,
displayName: homeUser.displayName,
avatarThumbUrl: homeUser.thumb.isNotEmpty ? homeUser.thumb : null,
parentConnectionId: connectionId,
plexHomeUserUuid: homeUser.uuid,
plexRestricted: homeUser.restricted,
plexAdmin: homeUser.admin,
plexProtected: homeUser.protected,
sortOrder: homeUser.admin ? 0 : 1,
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
lastUsedAt: lastUsedAt,
);
}
bool get isLocal => kind == ProfileKind.local;
bool get isPlexHome => kind == ProfileKind.plexHome;
/// True when entering this profile requires user-supplied PIN.
///
/// Locals: gated by their own [pinHash].
/// Plex Home: gated by Plex's own protected flag (`plexProtected`).
bool get isPinProtected => isLocal ? (pinHash != null && pinHash!.isNotEmpty) : plexProtected;
Profile copyWith({
String? id,
ProfileKind? kind,
String? displayName,
String? avatarThumbUrl,
bool clearAvatar = false,
String? pinHash,
bool clearPin = false,
String? parentConnectionId,
String? plexHomeUserUuid,
bool? plexRestricted,
bool? plexAdmin,
bool? plexProtected,
int? sortOrder,
DateTime? createdAt,
DateTime? lastUsedAt,
bool clearLastUsedAt = false,
}) {
return Profile(
id: id ?? this.id,
kind: kind ?? this.kind,
displayName: displayName ?? this.displayName,
avatarThumbUrl: clearAvatar ? null : (avatarThumbUrl ?? this.avatarThumbUrl),
pinHash: clearPin ? null : (pinHash ?? this.pinHash),
parentConnectionId: parentConnectionId ?? this.parentConnectionId,
plexHomeUserUuid: plexHomeUserUuid ?? this.plexHomeUserUuid,
plexRestricted: plexRestricted ?? this.plexRestricted,
plexAdmin: plexAdmin ?? this.plexAdmin,
plexProtected: plexProtected ?? this.plexProtected,
sortOrder: sortOrder ?? this.sortOrder,
createdAt: createdAt ?? this.createdAt,
lastUsedAt: clearLastUsedAt ? null : (lastUsedAt ?? this.lastUsedAt),
);
}
Map<String, Object?> toConfigJson() {
return switch (kind) {
ProfileKind.local => {'pinHash': pinHash},
ProfileKind.plexHome => {
'parentConnectionId': parentConnectionId,
'restricted': plexRestricted,
'admin': plexAdmin,
'protected': plexProtected,
},
};
}
factory Profile.fromRow({
required String id,
required String kind,
required String displayName,
required String? avatarThumbUrl,
required Map<String, Object?> json,
required int sortOrder,
required DateTime createdAt,
required DateTime? lastUsedAt,
}) {
final parsedKind = ProfileKind.fromId(kind);
return switch (parsedKind) {
ProfileKind.local => Profile(
id: id,
kind: parsedKind,
displayName: displayName,
avatarThumbUrl: avatarThumbUrl,
pinHash: json['pinHash'] as String?,
sortOrder: sortOrder,
createdAt: createdAt,
lastUsedAt: lastUsedAt,
),
ProfileKind.plexHome => Profile(
id: id,
kind: parsedKind,
displayName: displayName,
avatarThumbUrl: avatarThumbUrl,
parentConnectionId: json['parentConnectionId'] as String?,
plexRestricted: json['restricted'] as bool? ?? false,
plexAdmin: json['admin'] as bool? ?? false,
plexProtected: (json['protected'] as bool?) ?? (json['hasPassword'] as bool? ?? false),
sortOrder: sortOrder,
createdAt: createdAt,
lastUsedAt: lastUsedAt,
),
};
}
}
enum ProfileKind {
local,
plexHome;
String get id => switch (this) {
ProfileKind.local => 'local',
ProfileKind.plexHome => 'plex_home',
};
static ProfileKind fromId(String id) => switch (id) {
'local' => ProfileKind.local,
'plex_home' => ProfileKind.plexHome,
_ => throw ArgumentError('Unknown ProfileKind id: $id'),
};
}
/// Salted SHA-256 of the PIN. The salt is fixed (per-app) — this is a
/// social-barrier hash, not real authentication. The threat model is
/// "kid bypassing parent's profile", not "adversary with device access".
const _pinSalt = 'plezy-app-profile-pin-v1';
String computePinHash(String rawPin) {
final digest = sha256.convert(utf8.encode('$_pinSalt:$rawPin'));
return digest.toString();
}
bool verifyPin(String rawPin, String hash) {
return computePinHash(rawPin) == hash;
}
/// Deterministic id for a Plex Home profile so re-discovery is idempotent.
String plexHomeProfileId({required String accountConnectionId, required String homeUserUuid}) {
return 'plex-home-$accountConnectionId-$homeUserUuid';
}
/// Anchor on the trailing 36-char UUID — both `accountConnectionId` and
/// `homeUserUuid` may contain hyphens, so a `lastIndexOf('-')` would slice
/// inside the UUID itself.
final RegExp _trailingHomeUserUuidPattern = RegExp(
r'-([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$',
);
/// Inverse of [plexHomeProfileId]. Returns `null` if [id] doesn't match the
/// `plex-home-{accountConnectionId}-{homeUserUuid}` shape.
({String accountConnectionId, String homeUserUuid})? parsePlexHomeProfileId(String id) {
const prefix = 'plex-home-';
if (!id.startsWith(prefix)) return null;
final rest = id.substring(prefix.length);
final match = _trailingHomeUserUuidPattern.firstMatch(rest);
if (match == null) return null;
final accountId = rest.substring(0, match.start);
if (accountId.isEmpty) return null;
return (accountConnectionId: accountId, homeUserUuid: match.group(1)!);
}