mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-04-29 15:26:05 +02:00
refactor: add cleanup for expired subscriptions
This commit is contained in:
@@ -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<string>("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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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<SyncMode>(
|
||||
@@ -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({
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<RadioGroupItem value="Encrypted" id="sync-encrypted" />
|
||||
<Label htmlFor="sync-encrypted" className="cursor-pointer">
|
||||
<RadioGroupItem
|
||||
value="Encrypted"
|
||||
id="sync-encrypted"
|
||||
disabled={!canUseEncryption}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="sync-encrypted"
|
||||
className={
|
||||
canUseEncryption
|
||||
? "cursor-pointer"
|
||||
: "cursor-not-allowed opacity-50"
|
||||
}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{t("sync.mode.encrypted", "E2E Encrypted Sync")}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"sync.mode.encryptedDescription",
|
||||
"Encrypted before upload. Server never sees plaintext data.",
|
||||
)}
|
||||
{canUseEncryption
|
||||
? t(
|
||||
"sync.mode.encryptedDescription",
|
||||
"Encrypted before upload. Server never sees plaintext data.",
|
||||
)
|
||||
: t(
|
||||
"settings.encryption.requiresProOrOwner",
|
||||
"Profile encryption is available for Pro users and team owners.",
|
||||
)}
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
)}
|
||||
</p>
|
||||
|
||||
{hasE2ePassword ? (
|
||||
{!canUseEncryption ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"settings.encryption.requiresProOrOwner",
|
||||
"Profile encryption is available for Pro users and team owners.",
|
||||
)}
|
||||
</p>
|
||||
) : hasE2ePassword ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="default">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -120,7 +120,8 @@
|
||||
"removed": "暗号化パスワードが削除されました",
|
||||
"passwordSaved": "暗号化パスワードが設定されました",
|
||||
"passwordMismatch": "パスワードが一致しません",
|
||||
"passwordTooShort": "パスワードは8文字以上である必要があります"
|
||||
"passwordTooShort": "パスワードは8文字以上である必要があります",
|
||||
"requiresProOrOwner": "プロファイルの暗号化はProユーザーとチームオーナーのみ利用できます。"
|
||||
},
|
||||
"commercial": {
|
||||
"title": "商用ライセンス",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -120,7 +120,8 @@
|
||||
"removed": "Пароль шифрования удалён",
|
||||
"passwordSaved": "Пароль шифрования установлен",
|
||||
"passwordMismatch": "Пароли не совпадают",
|
||||
"passwordTooShort": "Пароль должен содержать не менее 8 символов"
|
||||
"passwordTooShort": "Пароль должен содержать не менее 8 символов",
|
||||
"requiresProOrOwner": "Шифрование профилей доступно для пользователей Pro и владельцев команд."
|
||||
},
|
||||
"commercial": {
|
||||
"title": "Коммерческая лицензия",
|
||||
|
||||
@@ -120,7 +120,8 @@
|
||||
"removed": "加密密码已删除",
|
||||
"passwordSaved": "加密密码已设置",
|
||||
"passwordMismatch": "密码不匹配",
|
||||
"passwordTooShort": "密码必须至少8个字符"
|
||||
"passwordTooShort": "密码必须至少8个字符",
|
||||
"requiresProOrOwner": "配置文件加密仅适用于Pro用户和团队所有者。"
|
||||
},
|
||||
"commercial": {
|
||||
"title": "商业许可",
|
||||
|
||||
Reference in New Issue
Block a user