From 4524b75c9de957cfd32fdb5de35a9f406dde2f36 Mon Sep 17 00:00:00 2001 From: ignacionelson Date: Thu, 24 Sep 2026 15:39:44 -0300 Subject: [PATCH] Let a hosted plan switch zip downloads off with downloads.zip A new capability, granted by both editions. Self-hosted installs keep zip downloads as they are. A platform removes it through PROJECTSEND_CAPABILITIES_DISABLED. The free shared instances do this, because building an archive holds the zips worker, the disk and a CPU on a server that thousands of accounts share. - The three zip routes sit behind capability:downloads.zip, so a hand-made request gets a 404, not just a missing button. - BuildZipDownloadJob refuses a build that was queued before the key went away. The row ends failed and is never stamped as started. StalledZipBuilds stays quiet when the key is off, so leftover rows raise no worker banner. - The zip buttons are hidden. In the portal, the checkboxes and the selection bar are hidden too, since they exist only to pick files for a zip. Staff /files keeps its checkboxes, which also drive bulk edit. - Archives already built are not touched. They expire on the normal purge schedule. - A guard test walks the router. It fails if any route that reaches ZipDownloadsController, or dispatches the build job, lacks the middleware. No API route builds zips today. The case goes last in the enum, because the control plane reads keys in enum order. --- .../Files/Jobs/BuildZipDownloadJob.php | 19 ++ app/Modules/Files/Queue/StalledZipBuilds.php | 11 + .../Platform/Capabilities/Capability.php | 20 +- resources/js/hooks/use-portal-files.ts | 6 + resources/js/pages/files/index.tsx | 16 +- .../pages/portal/themes/compact/my-files.tsx | 53 ++--- .../pages/portal/themes/default/my-files.tsx | 59 +++--- .../js/pages/portal/themes/drive/my-files.tsx | 45 +++-- .../pages/portal/themes/gallery/my-files.tsx | 35 ++-- resources/js/types/index.ts | 3 +- routes/web.php | 17 +- .../Files/ZipDownloadCapabilityTest.php | 190 ++++++++++++++++++ tests/Unit/CapabilityRegistryTest.php | 1 + 13 files changed, 383 insertions(+), 92 deletions(-) create mode 100644 tests/Feature/Files/ZipDownloadCapabilityTest.php diff --git a/app/Modules/Files/Jobs/BuildZipDownloadJob.php b/app/Modules/Files/Jobs/BuildZipDownloadJob.php index 90d7f100..0bbfcc03 100644 --- a/app/Modules/Files/Jobs/BuildZipDownloadJob.php +++ b/app/Modules/Files/Jobs/BuildZipDownloadJob.php @@ -11,6 +11,8 @@ use App\Modules\Files\Models\File; use App\Modules\Files\Scanning\FileAvailability; use App\Modules\Files\Models\Folder; use App\Modules\Files\Models\ZipDownload; +use App\Modules\Platform\Capabilities\Capability; +use App\Modules\Platform\Capabilities\CapabilityRegistry; use App\Modules\Platform\Settings\Setting; use App\Modules\Platform\Settings\Settings; use Illuminate\Bus\Queueable; @@ -87,6 +89,23 @@ class BuildZipDownloadJob implements ShouldQueue return; } + // A build queued before this installation was told to stop + // offering zips. The route refuses new ones; this refuses the ones + // already waiting, so the work the key exists to save is not done + // anyway. Checked before started_at is stamped, so the row goes + // straight from waiting to failed and never looks like a build in + // hand. Failed, not left pending: pending is polled by the page + // and counted by StalledZipBuilds, and neither should wait on a + // build that will never run. + if (! app(CapabilityRegistry::class)->has(Capability::ZipDownloads)) { + $zipDownload->update([ + 'status' => ZipDownload::STATUS_FAILED, + 'error' => 'Zip downloads are not available on this site.', + ]); + + return; + } + // Stamped before any of the work, because the only thing this is // for is telling "a worker has this in hand" apart from "nobody // is listening to the zips queue". A build that waits and never diff --git a/app/Modules/Files/Queue/StalledZipBuilds.php b/app/Modules/Files/Queue/StalledZipBuilds.php index 08b013c6..cf78edd9 100644 --- a/app/Modules/Files/Queue/StalledZipBuilds.php +++ b/app/Modules/Files/Queue/StalledZipBuilds.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace App\Modules\Files\Queue; use App\Modules\Files\Models\ZipDownload; +use App\Modules\Platform\Capabilities\Capability; +use App\Modules\Platform\Capabilities\CapabilityRegistry; use Illuminate\Support\Carbon; /** @@ -56,6 +58,15 @@ class StalledZipBuilds */ public function oldestUnstarted(): ?Carbon { + // An installation that does not offer zips has no reason to be + // serving their queue, and one that stopped offering them may + // still hold rows queued before it did. BuildZipDownloadJob fails + // those when a worker reaches them; until one does, they are not + // a worker problem worth a banner. + if (! app(CapabilityRegistry::class)->has(Capability::ZipDownloads)) { + return null; + } + if ($this->buildInHand()) { return null; } diff --git a/app/Modules/Platform/Capabilities/Capability.php b/app/Modules/Platform/Capabilities/Capability.php index bcbde230..c2ddcd43 100644 --- a/app/Modules/Platform/Capabilities/Capability.php +++ b/app/Modules/Platform/Capabilities/Capability.php @@ -169,6 +169,23 @@ enum Capability: string case AiConnector = 'ai.connector'; + // Both editions, and present by default: a self-hosted installation + // keeps zip downloads exactly as it has them. The key exists so a + // hosted plan can subtract it, and the reason is cost rather than + // trust. Building an archive holds the `zips` worker, the disk and a + // CPU for as long as it takes, and on an instance shared by thousands + // of free accounts one person's folder is everybody's wait. + // + // Closed at the route, all three verbs, so a hand-made POST is a 404 + // and not just a missing button. A build already queued when the key + // went away is refused by BuildZipDownloadJob and ends failed rather + // than pending. Archives already built are left alone and expire on + // their own schedule: taking a feature away never deletes anything. + // + // Last on purpose: keys are listed in enum order, and the control + // plane reads them in that order, so a new key goes at the end. + case ZipDownloads = 'downloads.zip'; + /** * @return list */ @@ -185,7 +202,8 @@ enum Capability: string self::UsersManage, self::CaptchaConfigure, - self::Branding => [Edition::Community, Edition::Cloud], + self::Branding, + self::ZipDownloads => [Edition::Community, Edition::Cloud], self::AttributionHide, self::StorageManaged, diff --git a/resources/js/hooks/use-portal-files.ts b/resources/js/hooks/use-portal-files.ts index bcd765a2..1a8db346 100644 --- a/resources/js/hooks/use-portal-files.ts +++ b/resources/js/hooks/use-portal-files.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; +import { useCapability } from '@/hooks/use-capability'; import { ALL, useListQuery } from '@/hooks/use-list-query'; import { useZipDownload } from '@/hooks/use-zip-download'; import { type MyFilesProps } from '@/types/portal'; @@ -14,6 +15,10 @@ import { type MyFilesProps } from '@/types/portal'; */ export function usePortalFiles({ folder, search, category, owner, sort, direction, pagination }: MyFilesProps) { const zip = useZipDownload(); + // Withheld on some hosted plans. In the portal, selecting rows exists + // only to zip them, so themes hide the checkboxes and the selection + // bar along with the folder's zip button when this is false. + const canZip = useCapability('downloads.zip'); const [selectedFileIds, setSelectedFileIds] = useState>(new Set()); const [selectedFolderIds, setSelectedFolderIds] = useState>(new Set()); @@ -61,6 +66,7 @@ export function usePortalFiles({ folder, search, category, owner, sort, directio return { zip, + canZip, selectedFileIds, selectedFolderIds, selectionCount, diff --git a/resources/js/pages/files/index.tsx b/resources/js/pages/files/index.tsx index 21063b40..1e2f7d3c 100644 --- a/resources/js/pages/files/index.tsx +++ b/resources/js/pages/files/index.tsx @@ -28,6 +28,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { ViewModeToggle } from '@/components/view-mode-toggle'; import { ZipDownloadDialog } from '@/components/zip-download-dialog'; +import { useCapability } from '@/hooks/use-capability'; import { ALL, useListQuery } from '@/hooks/use-list-query'; import { useTranslation } from '@/hooks/use-translation'; import { useViewMode } from '@/hooks/use-view-mode'; @@ -152,6 +153,9 @@ export default function FilesIndex({ const [panelTarget, setPanelTarget] = useState(null); const zip = useZipDownload(); + // Withheld on some hosted plans. Only the zip buttons go: selection + // also drives bulk edit here, so the checkboxes stay. + const canZip = useCapability('downloads.zip'); const [selectedFileIds, setSelectedFileIds] = useState>(new Set()); const [selectedFolderIds, setSelectedFolderIds] = useState>(new Set()); const selectionCount = selectedFileIds.size + selectedFolderIds.size; @@ -287,7 +291,7 @@ export default function FilesIndex({
- {folder !== null && !searching && ( + {canZip && folder !== null && !searching && ( + {canZip && ( + + )} {selectedFileIds.size === 0 ? ( diff --git a/resources/js/pages/portal/themes/compact/my-files.tsx b/resources/js/pages/portal/themes/compact/my-files.tsx index 5579f266..0ff1385a 100644 --- a/resources/js/pages/portal/themes/compact/my-files.tsx +++ b/resources/js/pages/portal/themes/compact/my-files.tsx @@ -55,6 +55,7 @@ export default function MyFilesCompact(props: MyFilesFolderManagementProps) { } = props; const { zip, + canZip, selectedFileIds, selectedFolderIds, selectionCount, @@ -116,7 +117,7 @@ export default function MyFilesCompact(props: MyFilesFolderManagementProps) { )} - {folder !== null && !searching && ( + {canZip && folder !== null && !searching && (