import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import '../models/plex/plex_home_user.dart'; part 'profile.freezed.dart'; /// Top-level profile — the user-facing identity in the app. /// /// Two kinds: /// - [LocalProfile]: a Plezy-only profile created by the user. May have /// an optional 4-digit PIN. /// - [PlexHomeProfile]: 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. @freezed sealed class Profile with _$Profile { const Profile._(); const factory Profile.local({ required String id, required String displayName, String? avatarThumbUrl, /// Hashed PIN if set. The raw PIN is never persisted; see [computePinHash]. String? pinHash, @Default(0) int sortOrder, required DateTime createdAt, DateTime? lastUsedAt, }) = LocalProfile; const factory Profile.plexHome({ required String id, required String displayName, String? avatarThumbUrl, /// The parent Plex account's connection id. String? parentConnectionId, /// The Plex Home user UUID. Used by the active-profile binder to call /// `/home/users/{uuid}/switch`. String? plexHomeUserUuid, @Default(false) bool plexRestricted, @Default(false) 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. @Default(false) bool plexProtected, @Default(0) int sortOrder, required DateTime createdAt, DateTime? lastUsedAt, }) = PlexHomeProfile; /// 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, }) => Profile.plexHome( id: plexHomeProfileId(accountConnectionId: connectionId, homeUserUuid: homeUser.uuid), 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, ); factory Profile.fromRow({ required String id, required String kind, required String displayName, required String? avatarThumbUrl, required Map json, required int sortOrder, required DateTime createdAt, required DateTime? lastUsedAt, }) { final parsedKind = ProfileKind.fromId(kind); return switch (parsedKind) { ProfileKind.local => Profile.local( id: id, displayName: displayName, avatarThumbUrl: avatarThumbUrl, pinHash: json['pinHash'] as String?, sortOrder: sortOrder, createdAt: createdAt, lastUsedAt: lastUsedAt, ), ProfileKind.plexHome => Profile.plexHome( id: id, 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, ), }; } bool get isLocal => this is LocalProfile; bool get isPlexHome => this is PlexHomeProfile; ProfileKind get kind => switch (this) { LocalProfile() => ProfileKind.local, PlexHomeProfile() => 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 => switch (this) { LocalProfile(:final pinHash) => pinHash != null && pinHash.isNotEmpty, PlexHomeProfile(:final plexProtected) => plexProtected, }; /// Hashed PIN, only set for [LocalProfile]. Returns null for plexHome. String? get pinHash => switch (this) { LocalProfile(:final pinHash) => pinHash, PlexHomeProfile() => null, }; /// Parent Plex account connection id, only set for [PlexHomeProfile]. String? get parentConnectionId => switch (this) { LocalProfile() => null, PlexHomeProfile(:final parentConnectionId) => parentConnectionId, }; /// Plex Home user UUID, only set for [PlexHomeProfile]. String? get plexHomeUserUuid => switch (this) { LocalProfile() => null, PlexHomeProfile(:final plexHomeUserUuid) => plexHomeUserUuid, }; bool get plexRestricted => switch (this) { LocalProfile() => false, PlexHomeProfile(:final plexRestricted) => plexRestricted, }; bool get plexAdmin => switch (this) { LocalProfile() => false, PlexHomeProfile(:final plexAdmin) => plexAdmin, }; bool get plexProtected => switch (this) { LocalProfile() => false, PlexHomeProfile(:final plexProtected) => plexProtected, }; Map toConfigJson() => switch (this) { LocalProfile(:final pinHash) => {'pinHash': pinHash}, PlexHomeProfile(:final parentConnectionId, :final plexRestricted, :final plexAdmin, :final plexProtected) => { 'parentConnectionId': parentConnectionId, 'restricted': plexRestricted, 'admin': plexAdmin, 'protected': plexProtected, }, }; } 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)!); }