From 23d25928fc16773a80de135a98415b14584d5a9b Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:49:47 +0400 Subject: [PATCH] refactor: add cleanup for expired subscriptions --- donut-sync/src/sync/internal.controller.ts | 38 ++++++ donut-sync/src/sync/sync.module.ts | 3 +- donut-sync/src/sync/sync.service.ts | 129 +++++++++++++++++++++ src-tauri/src/sync/engine.rs | 9 +- src/components/profile-sync-dialog.tsx | 47 ++++++-- src/components/settings-dialog.tsx | 17 ++- src/i18n/locales/en.json | 3 +- src/i18n/locales/es.json | 3 +- src/i18n/locales/fr.json | 3 +- src/i18n/locales/ja.json | 3 +- src/i18n/locales/pt.json | 3 +- src/i18n/locales/ru.json | 3 +- src/i18n/locales/zh.json | 3 +- 13 files changed, 247 insertions(+), 17 deletions(-) create mode 100644 donut-sync/src/sync/internal.controller.ts diff --git a/donut-sync/src/sync/internal.controller.ts b/donut-sync/src/sync/internal.controller.ts new file mode 100644 index 0000000..d5f71f2 --- /dev/null +++ b/donut-sync/src/sync/internal.controller.ts @@ -0,0 +1,38 @@ +import { + Body, + Controller, + Headers, + HttpCode, + Post, + UnauthorizedException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { SyncService } from "./sync.service.js"; + +@Controller("v1/internal") +export class InternalController { + private readonly internalKey: string | undefined; + + constructor( + private readonly syncService: SyncService, + private readonly configService: ConfigService, + ) { + this.internalKey = this.configService.get("INTERNAL_KEY"); + } + + @Post("cleanup-excess-profiles") + @HttpCode(200) + async cleanupExcessProfiles( + @Headers("x-internal-key") key: string, + @Body() body: { userId: string; maxProfiles: number }, + ) { + if (!this.internalKey || key !== this.internalKey) { + throw new UnauthorizedException("Invalid internal key"); + } + + return this.syncService.cleanupExcessProfiles( + body.userId, + body.maxProfiles, + ); + } +} diff --git a/donut-sync/src/sync/sync.module.ts b/donut-sync/src/sync/sync.module.ts index 40096f0..11ccc5c 100644 --- a/donut-sync/src/sync/sync.module.ts +++ b/donut-sync/src/sync/sync.module.ts @@ -1,10 +1,11 @@ import { Module } from "@nestjs/common"; import { AuthGuard } from "../auth/auth.guard.js"; +import { InternalController } from "./internal.controller.js"; import { SyncController } from "./sync.controller.js"; import { SyncService } from "./sync.service.js"; @Module({ - controllers: [SyncController], + controllers: [SyncController, InternalController], providers: [SyncService, AuthGuard], exports: [SyncService], }) diff --git a/donut-sync/src/sync/sync.service.ts b/donut-sync/src/sync/sync.service.ts index 55179d3..4d218be 100644 --- a/donut-sync/src/sync/sync.service.ts +++ b/donut-sync/src/sync/sync.service.ts @@ -554,6 +554,135 @@ export class SyncService implements OnModuleInit { this.changeSubject.next(event); } + async cleanupExcessProfiles( + userId: string, + maxProfiles: number, + ): Promise<{ deletedProfiles: string[]; remaining: number }> { + const userPrefix = `users/${userId}/`; + const profilePrefix = `${userPrefix}profiles/`; + + // List all profile directories + const profiles: { id: string; lastModified: Date }[] = []; + let continuationToken: string | undefined; + + do { + const result = await this.s3Client.send( + new ListObjectsV2Command({ + Bucket: this.bucket, + Prefix: profilePrefix, + Delimiter: "/", + MaxKeys: 1000, + ContinuationToken: continuationToken, + }), + ); + + if (result.CommonPrefixes) { + for (const cp of result.CommonPrefixes) { + if (!cp.Prefix) continue; + const profileId = cp.Prefix.replace(profilePrefix, "").replace( + /\/$/, + "", + ); + + // Get creation time from first object in the profile directory + const objects = await this.s3Client.send( + new ListObjectsV2Command({ + Bucket: this.bucket, + Prefix: cp.Prefix, + MaxKeys: 1, + }), + ); + + const firstObj = objects.Contents?.[0]; + profiles.push({ + id: profileId, + lastModified: firstObj?.LastModified || new Date(0), + }); + } + } + + continuationToken = result.NextContinuationToken; + } while (continuationToken); + + if (profiles.length <= maxProfiles) { + return { deletedProfiles: [], remaining: profiles.length }; + } + + // Sort newest first — delete newest excess profiles + profiles.sort( + (a, b) => b.lastModified.getTime() - a.lastModified.getTime(), + ); + + const excessCount = profiles.length - maxProfiles; + const toDelete = profiles.slice(0, excessCount); + const deletedProfiles: string[] = []; + + for (const profile of toDelete) { + const prefix = `${profilePrefix}${profile.id}/`; + + // Delete all objects under this profile + let delToken: string | undefined; + do { + const listResult = await this.s3Client.send( + new ListObjectsV2Command({ + Bucket: this.bucket, + Prefix: prefix, + MaxKeys: 1000, + ContinuationToken: delToken, + }), + ); + + const objects = listResult.Contents || []; + if (objects.length > 0) { + const deleteObjects = objects + .filter((obj): obj is typeof obj & { Key: string } => !!obj.Key) + .map((obj) => ({ Key: obj.Key })); + + if (deleteObjects.length > 0) { + await this.s3Client.send( + new DeleteObjectsCommand({ + Bucket: this.bucket, + Delete: { Objects: deleteObjects, Quiet: true }, + }), + ); + } + } + + delToken = listResult.NextContinuationToken; + } while (delToken); + + // Create tombstone + const tombstoneKey = `${userPrefix}tombstones/profiles/${profile.id}`; + const tombstoneData = JSON.stringify({ + prefix: `profiles/${profile.id}/`, + deleted_at: new Date().toISOString(), + reason: "excess_profile_cleanup", + }); + + await this.s3Client.send( + new PutObjectCommand({ + Bucket: this.bucket, + Key: tombstoneKey, + Body: tombstoneData, + ContentType: "application/json", + }), + ); + + deletedProfiles.push(profile.id); + this.logger.log( + `Cleaned up excess profile ${profile.id} for user ${userId}`, + ); + } + + // Report updated profile usage to backend + const remaining = profiles.length - deletedProfiles.length; + await this.reportProfileUsage(userId, remaining).catch((err) => + this.logger.warn(`Failed to report usage after cleanup: ${err.message}`), + ); + + return { deletedProfiles, remaining }; + } + /** * Check if the user has reached their profile limit. * Counts objects in the profiles/ prefix. diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index acdeb96..c94ac52 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -2087,8 +2087,15 @@ pub async fn set_profile_sync_mode( } } - // If switching to Encrypted, verify password and generate salt + // If switching to Encrypted, verify eligibility, password, and generate salt if new_mode == SyncMode::Encrypted { + // Only pro users and team owners can enable encryption + if let Some(state) = crate::cloud_auth::CLOUD_AUTH.get_user().await { + if state.user.plan == "team" && state.user.team_role.as_deref() != Some("owner") { + return Err("Profile encryption is available for Pro users and team owners.".to_string()); + } + } + if !encryption::has_e2e_password() { return Err("E2E password not set. Please set a password in Settings first.".to_string()); } diff --git a/src/components/profile-sync-dialog.tsx b/src/components/profile-sync-dialog.tsx index b19fc56..864f7d5 100644 --- a/src/components/profile-sync-dialog.tsx +++ b/src/components/profile-sync-dialog.tsx @@ -41,6 +41,10 @@ export function ProfileSyncDialog({ cloudUser.plan !== "free" && (cloudUser.subscriptionStatus === "active" || cloudUser.planPeriod === "lifetime"); + const canUseEncryption = + isCloudSyncEligible && + cloudUser != null && + (cloudUser.plan !== "team" || cloudUser.teamRole === "owner"); const [isSaving, setIsSaving] = useState(false); const [isSyncing, setIsSyncing] = useState(false); const [syncMode, setSyncMode] = useState( @@ -92,6 +96,11 @@ export function ProfileSyncDialog({ return; } + if (newMode === "Encrypted" && !canUseEncryption) { + showErrorToast(t("settings.encryption.requiresProOrOwner")); + return; + } + if (newMode === "Encrypted" && !hasE2ePassword) { showErrorToast(t("sync.mode.passwordRequired")); return; @@ -116,7 +125,15 @@ export function ProfileSyncDialog({ setIsSaving(false); } }, - [profile, hasConfig, hasE2ePassword, onSyncConfigOpen, onClose, t], + [ + profile, + hasConfig, + hasE2ePassword, + canUseEncryption, + onSyncConfigOpen, + onClose, + t, + ], ); const handleSyncNow = useCallback(async () => { @@ -225,16 +242,32 @@ export function ProfileSyncDialog({
- -
diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index 90d6bbd..06e2dda 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -39,6 +39,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { useCloudAuth } from "@/hooks/use-cloud-auth"; import { useCommercialTrial } from "@/hooks/use-commercial-trial"; import { useLanguage } from "@/hooks/use-language"; import type { PermissionType } from "@/hooks/use-permissions"; @@ -129,6 +130,13 @@ export function SettingsDialog({ isCameraAccessGranted, } = usePermissions(); const { trialStatus } = useCommercialTrial(); + const { user: cloudUser } = useCloudAuth(); + const canUseEncryption = + cloudUser != null && + cloudUser.plan !== "free" && + (cloudUser.subscriptionStatus === "active" || + cloudUser.planPeriod === "lifetime") && + (cloudUser.plan !== "team" || cloudUser.teamRole === "owner"); const { currentLanguage, changeLanguage, @@ -853,7 +861,14 @@ export function SettingsDialog({ )}

- {hasE2ePassword ? ( + {!canUseEncryption ? ( +

+ {t( + "settings.encryption.requiresProOrOwner", + "Profile encryption is available for Pro users and team owners.", + )} +

+ ) : hasE2ePassword ? (
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index db1d8d2..4c205c1 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -120,7 +120,8 @@ "removed": "Encryption password removed", "passwordSaved": "Encryption password set", "passwordMismatch": "Passwords do not match", - "passwordTooShort": "Password must be at least 8 characters" + "passwordTooShort": "Password must be at least 8 characters", + "requiresProOrOwner": "Profile encryption is available for Pro users and team owners." }, "commercial": { "title": "Commercial License", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 06bb68f..aaa551f 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -120,7 +120,8 @@ "removed": "Contraseña de cifrado eliminada", "passwordSaved": "Contraseña de cifrado establecida", "passwordMismatch": "Las contraseñas no coinciden", - "passwordTooShort": "La contraseña debe tener al menos 8 caracteres" + "passwordTooShort": "La contraseña debe tener al menos 8 caracteres", + "requiresProOrOwner": "El cifrado de perfiles está disponible para usuarios Pro y propietarios de equipos." }, "commercial": { "title": "Licencia Comercial", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index ee80cbd..5b79b1b 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -120,7 +120,8 @@ "removed": "Mot de passe de chiffrement supprimé", "passwordSaved": "Mot de passe de chiffrement défini", "passwordMismatch": "Les mots de passe ne correspondent pas", - "passwordTooShort": "Le mot de passe doit contenir au moins 8 caractères" + "passwordTooShort": "Le mot de passe doit contenir au moins 8 caractères", + "requiresProOrOwner": "Le chiffrement des profils est disponible pour les utilisateurs Pro et les propriétaires d'équipe." }, "commercial": { "title": "Licence commerciale", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 5c08d6e..e8cff3c 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -120,7 +120,8 @@ "removed": "暗号化パスワードが削除されました", "passwordSaved": "暗号化パスワードが設定されました", "passwordMismatch": "パスワードが一致しません", - "passwordTooShort": "パスワードは8文字以上である必要があります" + "passwordTooShort": "パスワードは8文字以上である必要があります", + "requiresProOrOwner": "プロファイルの暗号化はProユーザーとチームオーナーのみ利用できます。" }, "commercial": { "title": "商用ライセンス", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 3318234..45a8676 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -120,7 +120,8 @@ "removed": "Senha de criptografia removida", "passwordSaved": "Senha de criptografia definida", "passwordMismatch": "As senhas não coincidem", - "passwordTooShort": "A senha deve ter pelo menos 8 caracteres" + "passwordTooShort": "A senha deve ter pelo menos 8 caracteres", + "requiresProOrOwner": "A criptografia de perfis está disponível para usuários Pro e proprietários de equipe." }, "commercial": { "title": "Licença Comercial", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 98585fb..d5d0cf7 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -120,7 +120,8 @@ "removed": "Пароль шифрования удалён", "passwordSaved": "Пароль шифрования установлен", "passwordMismatch": "Пароли не совпадают", - "passwordTooShort": "Пароль должен содержать не менее 8 символов" + "passwordTooShort": "Пароль должен содержать не менее 8 символов", + "requiresProOrOwner": "Шифрование профилей доступно для пользователей Pro и владельцев команд." }, "commercial": { "title": "Коммерческая лицензия", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index a1420b1..bdf51bb 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -120,7 +120,8 @@ "removed": "加密密码已删除", "passwordSaved": "加密密码已设置", "passwordMismatch": "密码不匹配", - "passwordTooShort": "密码必须至少8个字符" + "passwordTooShort": "密码必须至少8个字符", + "requiresProOrOwner": "配置文件加密仅适用于Pro用户和团队所有者。" }, "commercial": { "title": "商业许可",