mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-30 15:50:54 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Edition>
|
||||
*/
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Set<number>>(new Set());
|
||||
const [selectedFolderIds, setSelectedFolderIds] = useState<Set<number>>(new Set());
|
||||
@@ -61,6 +66,7 @@ export function usePortalFiles({ folder, search, category, owner, sort, directio
|
||||
|
||||
return {
|
||||
zip,
|
||||
canZip,
|
||||
selectedFileIds,
|
||||
selectedFolderIds,
|
||||
selectionCount,
|
||||
|
||||
@@ -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<DetailsTarget | null>(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<Set<number>>(new Set());
|
||||
const [selectedFolderIds, setSelectedFolderIds] = useState<Set<number>>(new Set());
|
||||
const selectionCount = selectedFileIds.size + selectedFolderIds.size;
|
||||
@@ -287,7 +291,7 @@ export default function FilesIndex({
|
||||
<div className="flex items-start justify-between">
|
||||
<Heading title={t('Files')} description={t('Your shared file library')} />
|
||||
<div className="flex gap-2">
|
||||
{folder !== null && !searching && (
|
||||
{canZip && folder !== null && !searching && (
|
||||
<Button variant="outline" onClick={() => zip.start({ folder_ids: [folder.id] })}>
|
||||
<Archive className="size-4" />
|
||||
{t('Download as zip')}
|
||||
@@ -549,10 +553,12 @@ export default function FilesIndex({
|
||||
<div className="bg-muted/40 mb-3 flex items-center justify-between gap-3 rounded-lg border px-4 py-2">
|
||||
<p className="text-sm font-medium">{t(':count selected', { count: selectionCount })}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={downloadSelectionAsZip}>
|
||||
<Archive className="size-4" />
|
||||
{t('Download as zip')}
|
||||
</Button>
|
||||
{canZip && (
|
||||
<Button size="sm" onClick={downloadSelectionAsZip}>
|
||||
<Archive className="size-4" />
|
||||
{t('Download as zip')}
|
||||
</Button>
|
||||
)}
|
||||
{selectedFileIds.size === 0 ? (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
|
||||
@@ -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) {
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{folder !== null && !searching && (
|
||||
{canZip && folder !== null && !searching && (
|
||||
<Button variant="outline" size="sm" onClick={() => zip.start({ folder_ids: [folder.id] })}>
|
||||
<Archive className="size-4" />
|
||||
{t('Download as zip')}
|
||||
@@ -140,18 +141,20 @@ export default function MyFilesCompact(props: MyFilesFolderManagementProps) {
|
||||
<PortalBreadcrumb breadcrumb={breadcrumb} folderUrl={folderUrl} className="mb-2" />
|
||||
)}
|
||||
|
||||
<SelectionBar
|
||||
count={selectionCount}
|
||||
onDownload={downloadSelectionAsZip}
|
||||
onClear={clearSelection}
|
||||
className="mb-2 rounded-md px-3 py-1.5"
|
||||
/>
|
||||
{canZip && (
|
||||
<SelectionBar
|
||||
count={selectionCount}
|
||||
onDownload={downloadSelectionAsZip}
|
||||
onClear={clearSelection}
|
||||
className="mb-2 rounded-md px-3 py-1.5"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-none border border-neutral-300 dark:border-neutral-700">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-neutral-300 bg-neutral-100 text-neutral-500 uppercase dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-400">
|
||||
<th className="w-8 px-2 py-1 text-left font-medium"></th>
|
||||
{canZip && <th className="w-8 px-2 py-1 text-left font-medium"></th>}
|
||||
<th className="px-2 py-1 text-left font-medium">{t('Name')}</th>
|
||||
<th className="w-24 px-2 py-1 text-right font-medium">{t('Size')}</th>
|
||||
<th className="w-28 px-2 py-1 text-right font-medium">{t('Modified')}</th>
|
||||
@@ -161,7 +164,7 @@ export default function MyFilesCompact(props: MyFilesFolderManagementProps) {
|
||||
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||
{folders.length === 0 && files.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-muted-foreground px-4 py-8 text-center">
|
||||
<td colSpan={canZip ? 5 : 4} className="text-muted-foreground px-4 py-8 text-center">
|
||||
{searching ? t('No files or folders match your search.') : t('No files have been shared with you yet.')}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -169,13 +172,15 @@ export default function MyFilesCompact(props: MyFilesFolderManagementProps) {
|
||||
|
||||
{folders.map((row) => (
|
||||
<tr key={`folder-${row.id}`} className="hover:bg-neutral-100 dark:hover:bg-neutral-900">
|
||||
<td className="px-2 py-1">
|
||||
<Checkbox
|
||||
checked={selectedFolderIds.has(row.id)}
|
||||
onCheckedChange={() => toggleFolder(row.id)}
|
||||
aria-label={t('Select :name', { name: row.name })}
|
||||
/>
|
||||
</td>
|
||||
{canZip && (
|
||||
<td className="px-2 py-1">
|
||||
<Checkbox
|
||||
checked={selectedFolderIds.has(row.id)}
|
||||
onCheckedChange={() => toggleFolder(row.id)}
|
||||
aria-label={t('Select :name', { name: row.name })}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td colSpan={3} className="px-2 py-1">
|
||||
<Link href={folderUrl(row.id)} className="flex items-center gap-1.5 font-medium hover:underline">
|
||||
<FolderIcon className="size-3.5 shrink-0 text-neutral-500" />
|
||||
@@ -196,13 +201,15 @@ export default function MyFilesCompact(props: MyFilesFolderManagementProps) {
|
||||
|
||||
{files.map((file) => (
|
||||
<tr key={`file-${file.id}`} className="hover:bg-neutral-100 dark:hover:bg-neutral-900">
|
||||
<td className="px-2 py-1 align-top">
|
||||
<Checkbox
|
||||
checked={selectedFileIds.has(file.id)}
|
||||
onCheckedChange={() => toggleFile(file.id)}
|
||||
aria-label={t('Select :name', { name: file.name })}
|
||||
/>
|
||||
</td>
|
||||
{canZip && (
|
||||
<td className="px-2 py-1 align-top">
|
||||
<Checkbox
|
||||
checked={selectedFileIds.has(file.id)}
|
||||
onCheckedChange={() => toggleFile(file.id)}
|
||||
aria-label={t('Select :name', { name: file.name })}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-2 py-1">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<FilePreviewDialog
|
||||
|
||||
@@ -74,6 +74,7 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
|
||||
|
||||
const {
|
||||
zip,
|
||||
canZip,
|
||||
selectedFileIds,
|
||||
selectedFolderIds,
|
||||
selectionCount,
|
||||
@@ -124,7 +125,7 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{folder !== null && !searching && (
|
||||
{canZip && folder !== null && !searching && (
|
||||
<Button variant="outline" onClick={() => zip.start({ folder_ids: [folder.id] })}>
|
||||
<Archive className="size-4" />
|
||||
{t('Download as zip')}
|
||||
@@ -147,7 +148,9 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
|
||||
<ViewModeToggle value={viewMode} onChange={setViewMode} />
|
||||
</div>
|
||||
|
||||
<SelectionBar count={selectionCount} onDownload={downloadSelectionAsZip} onClear={clearSelection} className="mb-3" />
|
||||
{canZip && (
|
||||
<SelectionBar count={selectionCount} onDownload={downloadSelectionAsZip} onClear={clearSelection} className="mb-3" />
|
||||
)}
|
||||
|
||||
{folders.length === 0 && files.length === 0 && (
|
||||
<p className="text-muted-foreground rounded-lg border px-4 py-10 text-center text-sm">
|
||||
@@ -159,11 +162,13 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
|
||||
<div className="space-y-2">
|
||||
{folders.map((row) => (
|
||||
<div key={`folder-${row.id}`} className="bg-card flex items-center gap-3 rounded-lg border px-4 py-3">
|
||||
<Checkbox
|
||||
checked={selectedFolderIds.has(row.id)}
|
||||
onCheckedChange={() => toggleFolder(row.id)}
|
||||
aria-label={t('Select :name', { name: row.name })}
|
||||
/>
|
||||
{canZip && (
|
||||
<Checkbox
|
||||
checked={selectedFolderIds.has(row.id)}
|
||||
onCheckedChange={() => toggleFolder(row.id)}
|
||||
aria-label={t('Select :name', { name: row.name })}
|
||||
/>
|
||||
)}
|
||||
<Link href={folderUrl(row.id)} className="hover:bg-accent/40 -m-3 flex flex-1 items-center gap-3 rounded-lg p-3">
|
||||
<FolderIcon className="text-primary size-5 shrink-0" />
|
||||
<p className="text-sm font-medium">{row.name}</p>
|
||||
@@ -187,11 +192,13 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
|
||||
every row. The name takes the slack and
|
||||
the actions are one group at the end. */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<Checkbox
|
||||
checked={selectedFileIds.has(file.id)}
|
||||
onCheckedChange={() => toggleFile(file.id)}
|
||||
aria-label={t('Select :name', { name: file.name })}
|
||||
/>
|
||||
{canZip && (
|
||||
<Checkbox
|
||||
checked={selectedFileIds.has(file.id)}
|
||||
onCheckedChange={() => toggleFile(file.id)}
|
||||
aria-label={t('Select :name', { name: file.name })}
|
||||
/>
|
||||
)}
|
||||
<FilePreviewDialog
|
||||
previewUrl={preview_enabled ? route('files.preview', file.id) : null}
|
||||
mimeType={file.mime_type}
|
||||
@@ -268,12 +275,14 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
|
||||
key={`folder-${row.id}`}
|
||||
className="group hover:border-primary/50 relative flex flex-col items-center justify-center gap-2 rounded-xl border p-6 text-center transition hover:shadow-md"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedFolderIds.has(row.id)}
|
||||
onCheckedChange={() => toggleFolder(row.id)}
|
||||
aria-label={t('Select :name', { name: row.name })}
|
||||
className="absolute top-3 left-3"
|
||||
/>
|
||||
{canZip && (
|
||||
<Checkbox
|
||||
checked={selectedFolderIds.has(row.id)}
|
||||
onCheckedChange={() => toggleFolder(row.id)}
|
||||
aria-label={t('Select :name', { name: row.name })}
|
||||
className="absolute top-3 left-3"
|
||||
/>
|
||||
)}
|
||||
<Link href={folderUrl(row.id)} className="flex w-full flex-col items-center gap-2">
|
||||
<FolderIcon className="text-primary size-10 shrink-0" strokeWidth={1.5} />
|
||||
<span className="flex max-w-full items-center gap-1.5">
|
||||
@@ -299,12 +308,14 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
|
||||
key={`file-${file.id}`}
|
||||
className="group hover:border-primary/50 relative overflow-hidden rounded-xl border transition hover:shadow-lg"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedFileIds.has(file.id)}
|
||||
onCheckedChange={() => toggleFile(file.id)}
|
||||
aria-label={t('Select :name', { name: file.name })}
|
||||
className="bg-background/80 absolute top-3 left-3 z-10"
|
||||
/>
|
||||
{canZip && (
|
||||
<Checkbox
|
||||
checked={selectedFileIds.has(file.id)}
|
||||
onCheckedChange={() => toggleFile(file.id)}
|
||||
aria-label={t('Select :name', { name: file.name })}
|
||||
className="bg-background/80 absolute top-3 left-3 z-10"
|
||||
/>
|
||||
)}
|
||||
|
||||
{preview_enabled && isPreviewable(file.mime_type) ? (
|
||||
<FilePreviewDialog
|
||||
|
||||
@@ -56,6 +56,7 @@ export default function MyFilesDrive(props: MyFilesFolderManagementProps) {
|
||||
} = props;
|
||||
const {
|
||||
zip,
|
||||
canZip,
|
||||
selectedFileIds,
|
||||
selectedFolderIds,
|
||||
selectionCount,
|
||||
@@ -124,7 +125,7 @@ export default function MyFilesDrive(props: MyFilesFolderManagementProps) {
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{folder !== null && !searching && (
|
||||
{canZip && folder !== null && !searching && (
|
||||
<Button variant="outline" onClick={() => zip.start({ folder_ids: [folder.id] })}>
|
||||
<Archive className="size-4" />
|
||||
{t('Download as zip')}
|
||||
@@ -144,13 +145,15 @@ export default function MyFilesDrive(props: MyFilesFolderManagementProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<SelectionBar
|
||||
count={selectionCount}
|
||||
onDownload={downloadSelectionAsZip}
|
||||
onClear={clearSelection}
|
||||
className="mb-3 max-w-3xl border-blue-200 bg-blue-50 px-4 py-2 dark:border-blue-900 dark:bg-blue-950"
|
||||
downloadClassName="bg-blue-600 hover:bg-blue-700"
|
||||
/>
|
||||
{canZip && (
|
||||
<SelectionBar
|
||||
count={selectionCount}
|
||||
onDownload={downloadSelectionAsZip}
|
||||
onClear={clearSelection}
|
||||
className="mb-3 max-w-3xl border-blue-200 bg-blue-50 px-4 py-2 dark:border-blue-900 dark:bg-blue-950"
|
||||
downloadClassName="bg-blue-600 hover:bg-blue-700"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
{folders.length === 0 && files.length === 0 && (
|
||||
@@ -161,7 +164,7 @@ export default function MyFilesDrive(props: MyFilesFolderManagementProps) {
|
||||
|
||||
{(folders.length > 0 || files.length > 0) && (
|
||||
<div className="flex items-center gap-4 border-b border-neutral-200 px-2 pb-2 text-xs font-medium tracking-wide text-neutral-400 uppercase dark:border-neutral-800">
|
||||
<span className="w-5" />
|
||||
{canZip && <span className="w-5" />}
|
||||
<span className="flex-1">{t('Name')}</span>
|
||||
<span className="w-20 text-right">{t('Size')}</span>
|
||||
<span className="w-9" />
|
||||
@@ -173,11 +176,13 @@ export default function MyFilesDrive(props: MyFilesFolderManagementProps) {
|
||||
key={`folder-${row.id}`}
|
||||
className="flex items-center gap-4 border-b border-neutral-100 px-2 py-4 hover:bg-blue-50/70 dark:border-neutral-900 dark:hover:bg-blue-950/30"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedFolderIds.has(row.id)}
|
||||
onCheckedChange={() => toggleFolder(row.id)}
|
||||
aria-label={t('Select :name', { name: row.name })}
|
||||
/>
|
||||
{canZip && (
|
||||
<Checkbox
|
||||
checked={selectedFolderIds.has(row.id)}
|
||||
onCheckedChange={() => toggleFolder(row.id)}
|
||||
aria-label={t('Select :name', { name: row.name })}
|
||||
/>
|
||||
)}
|
||||
<Link href={folderUrl(row.id)} className="-my-4 flex flex-1 items-center gap-4 py-4">
|
||||
<FolderIcon className="size-6 shrink-0 text-blue-600" />
|
||||
<p className="flex items-center gap-1.5 text-sm font-medium text-neutral-800 dark:text-neutral-200">
|
||||
@@ -205,11 +210,13 @@ export default function MyFilesDrive(props: MyFilesFolderManagementProps) {
|
||||
key={`file-${file.id}`}
|
||||
className="flex items-center gap-4 border-b border-neutral-100 px-2 py-4 hover:bg-blue-50/70 dark:border-neutral-900 dark:hover:bg-blue-950/30"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedFileIds.has(file.id)}
|
||||
onCheckedChange={() => toggleFile(file.id)}
|
||||
aria-label={t('Select :name', { name: file.name })}
|
||||
/>
|
||||
{canZip && (
|
||||
<Checkbox
|
||||
checked={selectedFileIds.has(file.id)}
|
||||
onCheckedChange={() => toggleFile(file.id)}
|
||||
aria-label={t('Select :name', { name: file.name })}
|
||||
/>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-4">
|
||||
<FilePreviewDialog
|
||||
previewUrl={preview_enabled ? route('files.preview', file.id) : null}
|
||||
|
||||
@@ -57,6 +57,7 @@ export default function MyFilesGallery(props: MyFilesFolderManagementProps) {
|
||||
} = props;
|
||||
const {
|
||||
zip,
|
||||
canZip,
|
||||
selectedFileIds,
|
||||
selectedFolderIds,
|
||||
selectionCount,
|
||||
@@ -125,7 +126,7 @@ export default function MyFilesGallery(props: MyFilesFolderManagementProps) {
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{folder !== null && !searching && (
|
||||
{canZip && folder !== null && !searching && (
|
||||
<Button variant="outline" onClick={() => zip.start({ folder_ids: [folder.id] })}>
|
||||
<Archive className="size-4" />
|
||||
{t('Download as zip')}
|
||||
@@ -140,7 +141,9 @@ export default function MyFilesGallery(props: MyFilesFolderManagementProps) {
|
||||
<PortalBreadcrumb breadcrumb={breadcrumb} folderUrl={folderUrl} className="mb-4" />
|
||||
)}
|
||||
|
||||
<SelectionBar count={selectionCount} onDownload={downloadSelectionAsZip} onClear={clearSelection} className="mb-4" />
|
||||
{canZip && (
|
||||
<SelectionBar count={selectionCount} onDownload={downloadSelectionAsZip} onClear={clearSelection} className="mb-4" />
|
||||
)}
|
||||
|
||||
{folders.length === 0 && files.length === 0 && (
|
||||
<p className="text-muted-foreground rounded-lg border px-4 py-10 text-center text-sm">
|
||||
@@ -154,12 +157,14 @@ export default function MyFilesGallery(props: MyFilesFolderManagementProps) {
|
||||
key={`folder-${row.id}`}
|
||||
className="relative flex flex-col items-center justify-center gap-2 rounded-xl border p-6 text-center transition hover:border-violet-400 hover:shadow-md"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedFolderIds.has(row.id)}
|
||||
onCheckedChange={() => toggleFolder(row.id)}
|
||||
aria-label={t('Select :name', { name: row.name })}
|
||||
className="absolute top-3 left-3"
|
||||
/>
|
||||
{canZip && (
|
||||
<Checkbox
|
||||
checked={selectedFolderIds.has(row.id)}
|
||||
onCheckedChange={() => toggleFolder(row.id)}
|
||||
aria-label={t('Select :name', { name: row.name })}
|
||||
className="absolute top-3 left-3"
|
||||
/>
|
||||
)}
|
||||
<Link href={folderUrl(row.id)} className="flex w-full flex-col items-center gap-2">
|
||||
<FolderIcon className="size-10 shrink-0 text-violet-600" strokeWidth={1.5} />
|
||||
<p className="flex w-full items-center justify-center gap-1.5 truncate text-sm font-medium">
|
||||
@@ -183,12 +188,14 @@ export default function MyFilesGallery(props: MyFilesFolderManagementProps) {
|
||||
key={`file-${file.id}`}
|
||||
className="group relative overflow-hidden rounded-xl border transition hover:border-violet-400 hover:shadow-lg"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedFileIds.has(file.id)}
|
||||
onCheckedChange={() => toggleFile(file.id)}
|
||||
aria-label={t('Select :name', { name: file.name })}
|
||||
className="bg-background/80 absolute top-3 left-3 z-10"
|
||||
/>
|
||||
{canZip && (
|
||||
<Checkbox
|
||||
checked={selectedFileIds.has(file.id)}
|
||||
onCheckedChange={() => toggleFile(file.id)}
|
||||
aria-label={t('Select :name', { name: file.name })}
|
||||
className="bg-background/80 absolute top-3 left-3 z-10"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Over the thumbnail, not beside the name: a card's
|
||||
name line is a few characters wide, and an inline
|
||||
|
||||
@@ -51,7 +51,8 @@ export type Capability =
|
||||
| 'branding.customize'
|
||||
| 'attribution.hide'
|
||||
| 'captcha.configure'
|
||||
| 'captcha.managed_keys';
|
||||
| 'captcha.managed_keys'
|
||||
| 'downloads.zip';
|
||||
|
||||
/**
|
||||
* A sidebar entry contributed by a package — see
|
||||
|
||||
+12
-5
@@ -222,11 +222,18 @@ Route::middleware(['auth'])->group(function () {
|
||||
// Named bucket, as every throttle in this app must be: a bare
|
||||
// `throttle:` keys on sha1(domain|ip) and would share one counter with
|
||||
// every other bare throttle rather than with this route.
|
||||
Route::post('zip-downloads', [ZipDownloadsController::class, 'store'])
|
||||
->middleware('throttle:10,1,zip-downloads')
|
||||
->name('zip-downloads.store');
|
||||
Route::get('zip-downloads/{zipDownload}', [ZipDownloadsController::class, 'show'])->name('zip-downloads.show');
|
||||
Route::get('zip-downloads/{zipDownload}/download', [ZipDownloadsController::class, 'download'])->name('zip-downloads.download');
|
||||
//
|
||||
// Behind capability:downloads.zip, all three, so a plan that withholds
|
||||
// zips answers a hand-made request with 404 rather than only hiding
|
||||
// the button. ZipDownloadRoutesGuardTest fails on any zip route that
|
||||
// is missing it.
|
||||
Route::middleware('capability:downloads.zip')->group(function () {
|
||||
Route::post('zip-downloads', [ZipDownloadsController::class, 'store'])
|
||||
->middleware('throttle:10,1,zip-downloads')
|
||||
->name('zip-downloads.store');
|
||||
Route::get('zip-downloads/{zipDownload}', [ZipDownloadsController::class, 'show'])->name('zip-downloads.show');
|
||||
Route::get('zip-downloads/{zipDownload}/download', [ZipDownloadsController::class, 'download'])->name('zip-downloads.download');
|
||||
});
|
||||
|
||||
// Resumable chunked uploads (Uppy aws-s3 multipart contract). Shared
|
||||
// by staff and clients alike — ChunkedUploadsController's only
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Http\Controllers\ZipDownloadsController;
|
||||
use App\Modules\Files\Jobs\BuildZipDownloadJob;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\ZipDownload;
|
||||
use App\Modules\Files\Queue\StalledZipBuilds;
|
||||
use App\Modules\Platform\Capabilities\Edition;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Routing\Route as RoutingRoute;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/*
|
||||
* `downloads.zip` is granted by both editions and subtracted by a hosted
|
||||
* plan through PROJECTSEND_CAPABILITIES_DISABLED. What that has to mean:
|
||||
* the three routes are gone rather than hidden, a build already queued
|
||||
* does no work, and nothing already built is touched.
|
||||
*/
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake('files');
|
||||
$this->admin = User::factory()->create();
|
||||
app(Settings::class)->set(Setting::MaxZipDownloadSizeMb, 2048);
|
||||
});
|
||||
|
||||
function withoutZipDownloads(): void
|
||||
{
|
||||
// CapabilityRegistry is bound, not a singleton, so the next resolve
|
||||
// reads this.
|
||||
config(['projectsend.capabilities_disabled' => 'downloads.zip']);
|
||||
}
|
||||
|
||||
function zipCapabilityFile(User $as): File
|
||||
{
|
||||
test()->actingAs($as)->post('/files', [
|
||||
'file' => UploadedFile::fake()->create('report.pdf', 4, 'application/pdf'),
|
||||
'name' => '',
|
||||
'description' => '',
|
||||
]);
|
||||
|
||||
return File::query()->latest('id')->firstOrFail();
|
||||
}
|
||||
|
||||
test('staff get 404 on all three zip routes when the capability is withheld', function () {
|
||||
$file = zipCapabilityFile($this->admin);
|
||||
$ready = $this->actingAs($this->admin)->postJson('/zip-downloads', ['file_ids' => [$file->id]])->assertOk();
|
||||
$id = $ready->json('id');
|
||||
|
||||
withoutZipDownloads();
|
||||
|
||||
$this->actingAs($this->admin)->postJson('/zip-downloads', ['file_ids' => [$file->id]])->assertNotFound();
|
||||
$this->actingAs($this->admin)->getJson("/zip-downloads/{$id}")->assertNotFound();
|
||||
$this->actingAs($this->admin)->get("/zip-downloads/{$id}/download")->assertNotFound();
|
||||
|
||||
// Refused at the door: no row was written for the second request.
|
||||
expect(ZipDownload::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('clients get 404 on all three zip routes when the capability is withheld', function () {
|
||||
$client = User::factory()->client()->create();
|
||||
$file = zipCapabilityFile($this->admin);
|
||||
$this->actingAs($this->admin)->post("/files/{$file->id}/assignments", ['type' => 'client', 'id' => $client->id]);
|
||||
$id = $this->actingAs($client)->postJson('/zip-downloads', ['file_ids' => [$file->id]])->assertOk()->json('id');
|
||||
|
||||
withoutZipDownloads();
|
||||
|
||||
$this->actingAs($client)->postJson('/zip-downloads', ['file_ids' => [$file->id]])->assertNotFound();
|
||||
$this->actingAs($client)->getJson("/zip-downloads/{$id}")->assertNotFound();
|
||||
$this->actingAs($client)->get("/zip-downloads/{$id}/download")->assertNotFound();
|
||||
});
|
||||
|
||||
test('the zip routes are open on both editions by default', function (Edition $edition) {
|
||||
config(['projectsend.edition' => $edition]);
|
||||
$file = zipCapabilityFile($this->admin);
|
||||
|
||||
$this->actingAs($this->admin)->postJson('/zip-downloads', ['file_ids' => [$file->id]])->assertOk();
|
||||
})->with([Edition::Community, Edition::Cloud]);
|
||||
|
||||
test('a build queued before the capability was withheld is refused and ends failed', function () {
|
||||
$row = ZipDownload::query()->create([
|
||||
'requested_by' => $this->admin->id,
|
||||
'file_ids' => [zipCapabilityFile($this->admin)->id],
|
||||
'status' => ZipDownload::STATUS_PENDING,
|
||||
]);
|
||||
|
||||
withoutZipDownloads();
|
||||
|
||||
(new BuildZipDownloadJob($row->id))->handle();
|
||||
|
||||
$row->refresh();
|
||||
expect($row->status)->toBe(ZipDownload::STATUS_FAILED)
|
||||
->and($row->error)->toBe('Zip downloads are not available on this site.')
|
||||
// Never stamped, so it never looked like a build in hand.
|
||||
->and($row->started_at)->toBeNull()
|
||||
->and($row->path)->toBeNull();
|
||||
|
||||
Storage::disk('files')->assertMissing("zips/{$row->id}.zip");
|
||||
});
|
||||
|
||||
test('an archive already built is left on disk when the capability is withheld', function () {
|
||||
// A downgrade never deletes data. The archive stays until
|
||||
// PurgeZipDownloadsCommand ages it out like any other.
|
||||
$file = zipCapabilityFile($this->admin);
|
||||
$row = ZipDownload::query()->findOrFail(
|
||||
$this->actingAs($this->admin)->postJson('/zip-downloads', ['file_ids' => [$file->id]])->json('id'),
|
||||
);
|
||||
|
||||
withoutZipDownloads();
|
||||
Artisan::call('projectsend:purge-zip-downloads');
|
||||
|
||||
expect($row->refresh()->status)->toBe(ZipDownload::STATUS_READY);
|
||||
Storage::disk('files')->assertExists($row->path);
|
||||
});
|
||||
|
||||
test('rows left waiting after the capability is withheld raise no worker banner', function () {
|
||||
$row = ZipDownload::query()->create([
|
||||
'requested_by' => $this->admin->id,
|
||||
'status' => ZipDownload::STATUS_PENDING,
|
||||
]);
|
||||
$row->forceFill(['created_at' => now()->subMinutes(30)])->save();
|
||||
|
||||
expect(app(StalledZipBuilds::class)->oldestUnstarted())->not->toBeNull();
|
||||
|
||||
withoutZipDownloads();
|
||||
|
||||
expect(app(StalledZipBuilds::class)->oldestUnstarted())->toBeNull();
|
||||
});
|
||||
|
||||
test('the key comes and goes in the status document', function () {
|
||||
Artisan::call('projectsend:status', ['--json' => true]);
|
||||
expect(json_decode(Artisan::output(), true)['capabilities'])->toContain('downloads.zip');
|
||||
|
||||
withoutZipDownloads();
|
||||
|
||||
Artisan::call('projectsend:status', ['--json' => true]);
|
||||
expect(json_decode(Artisan::output(), true)['capabilities'])->not->toContain('downloads.zip');
|
||||
});
|
||||
|
||||
test('the key comes and goes in the shared Inertia props', function () {
|
||||
$this->actingAs($this->admin)->get('/files')
|
||||
->assertInertia(fn ($page) => $page->where('capabilities', fn ($keys) => collect($keys)->contains('downloads.zip')));
|
||||
|
||||
withoutZipDownloads();
|
||||
|
||||
$this->actingAs($this->admin)->get('/files')
|
||||
->assertInertia(fn ($page) => $page->where('capabilities', fn ($keys) => ! collect($keys)->contains('downloads.zip')));
|
||||
});
|
||||
|
||||
/*
|
||||
* The guard. Any route, web or API, core's or a package's, that ends in
|
||||
* ZipDownloadsController — or in a controller that queues a zip build —
|
||||
* must carry the capability. A new zip route that forgets it would build
|
||||
* archives on exactly the instances that were told not to.
|
||||
*/
|
||||
test('every route that reaches a zip build carries capability:downloads.zip', function () {
|
||||
$zipRoutes = collect(Route::getRoutes()->getRoutes())
|
||||
->filter(function (RoutingRoute $route): bool {
|
||||
$controller = $route->getControllerClass();
|
||||
|
||||
if ($controller === null || ! class_exists($controller)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($controller === ZipDownloadsController::class) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// A dispatch, not a mention: several settings controllers
|
||||
// name the job in a comment.
|
||||
$source = (string) file_get_contents((string) (new ReflectionClass($controller))->getFileName());
|
||||
|
||||
return preg_match('/BuildZipDownloadJob::dispatch|new\s+BuildZipDownloadJob\b/', $source) === 1;
|
||||
});
|
||||
|
||||
// Otherwise a refactor that renamed the controller would leave this
|
||||
// test green over nothing.
|
||||
expect($zipRoutes)->toHaveCount(3);
|
||||
|
||||
foreach ($zipRoutes as $route) {
|
||||
expect(in_array('capability:downloads.zip', $route->gatherMiddleware(), true))
|
||||
->toBeTrue("{$route->uri()} is missing capability:downloads.zip");
|
||||
}
|
||||
});
|
||||
@@ -75,6 +75,7 @@ test('enabledKeys returns the string keys of enabled capabilities', function ()
|
||||
'captcha.managed_keys',
|
||||
'platform.managed',
|
||||
'ai.connector',
|
||||
'downloads.zip',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user