12 Commits

Author SHA1 Message Date
ignacionelson fba5f30436 Release 2.4.0 2026-09-08 09:33:14 -03:00
ignacionelson 0f66f9030c Cut the unreleased notes down to what an operator needs
Each entry was three paragraphs explaining itself. Somebody deciding
whether to upgrade reads a list, and a list that takes ten minutes is one
they skim — so the reasoning is gone and the fact is what is left.

What stayed long is Upgrade notes, deliberately: those are the two things
somebody has to *do*, and a one-liner that says "allow headroom" without
saying how much or when is a note they have to come back and ask about.

This makes the section shorter than 2.3.0 and 2.2.1 above it. Those are
published and stay as they are; the style changes from here.
2026-09-08 09:06:00 -03:00
ignacionelson 7c16733c16 Stop a managed instance being able to hide the project news
I shipped both daily calls as the same kind of thing — an operator's
preference — and only one of them is. That was wrong in the direction that
matters, because it handed a decision over rather than keeping it.

An update notice on a hosted tenant is useless: they cannot act on it, the
image is ours, and the screen that would show it is closed by capability.
So that check does not run there at all, which is right and unchanged.

News is the reverse. Announcements about the product are exactly what a
hosted customer should be told, and a Cloud client with view_news sees
that card today. One administrator switching it off for everybody on that
instance is not a decision the platform meant to hand over — so on a
managed instance the news now runs whatever any setting says, including a
row left behind by an instance that used to be self-hosted.

Capability::NewsConfigure, Community-only, and the thing it gates is the
*choice* rather than the news. A self-hosted operator keeps the switch,
because there nobody else decides what their installation reaches out for.
An edition difference through the capability registry rather than an
edition check, as everything here is.

Gated in all three places rather than only the screen: the command ignores
the setting without the capability, the controller neither sends nor reads
the field, and the checkbox is absent. There is a test that a hand-crafted
PATCH cannot do what the missing checkbox could not, and the guard is
proved load-bearing — remove it and the managed-instance test goes red.

The changelog and product highlights said "two switches" and now say what
is actually true, including that neither appears on Cloud and why they are
absent for opposite reasons.
2026-09-08 02:34:00 -03:00
ignacionelson d7d7acce85 Put the announcement behind the header icon too, from one source
A message worth showing was only on the dashboard, which means somebody
who works in Files and Clients all day never meets it. It now also sits
behind an icon next to the notification bell, and that is on every page.

**One shared prop, not two.** "The same message in both places" is the
requirement, and two props would have drifted the first time anybody
edited one — so the hook moved out of DashboardController into
HandleInertiaRequests, and the dashboard reads the same shared value the
header does. The band and the dropdown also share the component that
renders the words, for the same reason: the reliable way to keep two
renderings identical is not to have two.

Renamed with it. ResolvingDashboardCallout was accurate for about an hour
and became a lie the moment it appeared somewhere else; it is
ResolvingAnnouncement now, and the prop is `announcement`. Free to rename
because nothing has shipped yet — the only other reference was
cloud-modules', by string, updated alongside.

The icon follows UpdateAvailableIcon beside it: absent entirely when there
is nothing to say rather than a dead control, and a plain dot instead of a
count, because there is only ever one of these and a "1" would invite
somebody to look for the second.

Two tests worth naming. One asserts the message reaches a page that is not
the dashboard, which is the whole point of the addition. The other asserts
a client is shown nothing even from a listener that sets it
unconditionally — a client's header carries the bell too, and staff
messages must not reach it however careless the listener.
2026-09-08 02:17:56 -03:00
ignacionelson 334b11d562 Give packages a way into the sidebar and the top of the dashboard
Two seams, in the shape docs/extension-points-architecture.md settles on:
a Laravel event with a mutable payload, dispatched unconditionally, and
with nothing listening the documented default holds. A community
installation gets an empty list and a null callout, which is exactly what
it had before.

ResolvingNavigationLinks exists because the sidebar is a hardcoded array
in app-sidebar.tsx, so a package could not contribute to it at all — the
nav entry was a separate manual edit every time a package grew a screen,
and being manual it was forgotten more than once. Staff-only, decided in
HandleInertiaRequests rather than trusted to each listener: these render
in the administration area, and a client's portal shows their own files
and nothing about the installation. There is a test that a listener adding
unconditionally still reaches no client.

ResolvingDashboardCallout is one band above the widget grid rather than a
widget in it. The grid is a closed list of keys that dashboard.tsx renders
one by one and each viewer arranges, so a message that mattered would sit
wherever somebody dragged it, or under a fold, or switched off. One at a
time, first listener wins: a dashboard that can accumulate banners
accumulates them, and the second is what teaches people to skip the first.

Core learns nothing about what either seam carries. Titles, URLs and copy
all arrive from the listener, and that is not fastidiousness — the first
caller is the hosted edition's link to its own customer portal and its
pitch to free instances, which is commercial copy belonging to one
offering and has no business sitting in the public repository because the
sidebar happens to live here.

An external link renders as a plain anchor opening in a new tab, never an
Inertia <Link>: Link expects a page component back and another origin will
not give it one, so it fails without saying so. It is also never marked
active — nothing outside this app is the page you are on.
2026-09-08 02:07:08 -03:00
ignacionelson da1f432d87 Let an installation stop calling home, two different ways
Every instance reached projectsend.org twice a day and an operator could
stop neither. The news feed had no switch of any kind — FetchNewsCommand
went straight to the request, touching Settings only to write results back.
The update check had one, but its default is on, and a managed fleet had
been setting PROJECTSEND_CHECK_FOR_UPDATES=false for months against code
that reads no such variable: check_for_updates is a database setting, so
the environment never touched it and updates were enabled fleet-wide the
whole time.

They look like one problem and are two, which is why they are fixed
differently.

**The news feed gets a Setting**, its own key, default on. A Cloud client
with view_news sees that card today — DashboardController gates it on the
permission alone, with a comment saying in as many words that it is both
editions and carries no capability. So switching it off is an operator's
choice rather than an edition's, and it must stay reachable everywhere.
Its own key rather than riding on check_for_updates because they are two
different wants: "do not tell me about releases" and "do not show me the
project's news" are asked separately, and an installation with no outbound
access at all wants both.

**The update check gets a capability guard**, ahead of the setting it
already had, and deliberately not a Setting of its own. On a managed
installation the result is unreachable rather than unwanted: the
dashboard's System card and the update UI are both gated on
Capability::SystemUpdates, which is Community-only, and the image is
chosen by whoever provisioned the instance. A Setting would encode a fact
about the edition as a preference — leaving it switchable back on per
tenant, buying a nightly call for a number no screen can draw, and putting
the reason in a provisioning script rather than beside the code. A
self-hosted install holds the capability and loses nothing: its own
setting still decides.

Both guards return success rather than failure. A scheduled task that was
asked not to run has not failed, and reporting it as one would put a red
line in the scheduler history every night for an installation behaving
exactly as configured.

The news switch is on the General settings screen, outside the
can_manage_updates block that hides the update toggle where the capability
is absent — a setting only reachable by editing a database row is a row,
not a switch. Seven tests, and the two that matter go red when either
guard is removed. Sixteen locales translated in the same commit rather
than left for the pass, since a release is close.
2026-09-08 01:27:08 -03:00
ignacionelson 82dd475f8f Write up what #1724 and #1733 mean for an operator
Two entries under Unreleased, both for the same reason: an operator would
otherwise be surprised.

The shorter download link is a behaviour change with a cost attached — a
resumed download more than a minute old is refused where an hour tolerated
it — so it says that plainly rather than only advertising the benefit. It
also says who is not affected, since installations on local disk never used
one of these links at all, and neither do zip bundles.

The upload fix is an ordinary bug fix and would normally need no entry, but
it moves peak temporary disk from "the file plus one part" to "the file
twice over" while assembling. That is a sizing question somebody with a
small temp volume has to answer, so it gets an upgrade note. Nothing to
configure — just headroom.
2026-09-07 19:25:30 -03:00
ignacionelson b758fca19c Merge pull request #1724 from fix/assemble-keeps-parts-for-retry
Keep an upload's parts until its bytes are stored
2026-09-07 19:24:06 -03:00
ignacionelson 02946abf85 Stop the delivery docblock naming nginx as the only local path
#1733 explains its two lifetimes by contrasting a presigned URL with
X-Accel-Redirect, "nginx serves these bytes, now, to this request". That
was true when the branch was written and stopped being true on 1 September,
when FileDelivery gave the local path four methods — auto, nginx, xsendfile
and PHP streaming.

The argument survives intact: every one of those authorises exactly one
response and nothing that outlives it, which is the property the contrast
rests on. Only the naming was stale, and a docblock that says "nginx" to
an operator running Apache reads as "this does not apply to me".

Found resolving the merge, not by the author — the branch predates the
change it collided with.
2026-09-07 19:24:00 -03:00
ignacionelson b7ac44e77b Merge pull request #1733 from fix/presigned-download-window
Give a download's presigned URL a minute rather than an hour

Conflicted against FileDelivery, which landed on main after this branch
was written: main added a constructor where the branch added two
constants. Both belong; the resolution keeps each.
2026-09-07 19:23:52 -03:00
denkfabrik-li 5a9133bb07 Give a download's presigned URL a minute rather than an hour
StoredFileResponse hands external storage a presigned URL for an hour,
whatever the delivery is for. That URL is a bearer credential: whoever
holds it fetches the file without passing any of the caller's checks
again, and it outlives them. A download cap spent in the meantime, an
expires_at that falls inside the hour, an assignment withdrawn -- none of
them reach it, and nothing here can revoke one. It is also forwardable,
which the local path is not: X-Accel-Redirect authorises one response to
one request.

The two deliveries do not need the same window, so they no longer share
one.

A download has to survive being followed -- a redirect and a request --
which a minute covers with room to spare. An object store checks the
signature when the request arrives rather than while it runs, so a
transfer that starts inside the window finishes however long it takes.

A preview keeps the hour, because it is watched rather than fetched: the
player holds the URL and issues a Range request every time somebody seeks
past the buffer, so a minute would break playback of anything longer than
a minute. The class docblock now says that this is the trade being made,
instead of leaving it in a single number.

Two tests, one per window. Without the fix the download link is an hour
long.
2026-08-28 06:40:53 +02:00
denkfabrik-li f2b705beee Keep an upload's parts until its bytes are stored
complete() holds a lock whose comment promises "the lock's TTL releases
the claim if a completion dies mid-flight, so a later retry still works".
A retry has nothing to work from but the parts, and assemble() unlinked
each one inside the loop that read it -- so everything that can fail
afterwards took the retry with it.

Measured on main, with a disk refusing the write (the case the guard forty
lines further down was written for, found against a real GCS bucket):

  first complete  → 422, 0 parts left, the half-written copy left behind
  retry           → 422 "Upload is incomplete: missing parts."

For good: listParts() is empty, so no later attempt can ever succeed, and
the client has to send the whole file again. The abandoned copy sat in the
session directory until the sweeper came round.

The parts now go when abort() clears the session directory -- which
already ran on success -- and a failure deletes only the half-written copy
it made. The cost is temp space: peak usage during assembly is the whole
file twice over rather than the file plus one part. The docblock says so.

Also checked while here: every read and every write in the concatenation.
A failing fwrite is loud in practice, since Laravel's error handler turns
the warning into an ErrorException, but loud there is a 500 carrying a PHP
message where this method's other storage failure is a sentence the person
uploading can act on. A short write arriving without a warning would be
worse: the byte count and the checksum describe the buffer that was read,
so an unchecked one records a truncated file with a checksum matching
bytes that were never stored.

Two tests: the retry after a refused write now succeeds, and a temporary
directory that refuses writes (/dev/full, skipped where it does not exist)
fails the upload with this method's own message. Without the fix both go
red.
2026-08-28 06:40:47 +02:00
43 changed files with 1264 additions and 137 deletions
+41 -16
View File
@@ -13,27 +13,52 @@ Anything under **Upgrade notes** is something you have to do, not something we d
This section collects changes as they land; the release process turns it into a numbered entry
when a version is cut.
## 2.4.0 — 8 September 2026
Clients can now look after the files they uploaded, and this release closes three ways somebody
could see a little more than they should.
**New**
- **Clients can edit and delete the files they uploaded**, with the name, description, expiry,
categories, download limit and public flag each behind the permission that already governs it.
A file shared *with* a client is still not theirs to touch.
- **A switch to stop this installation fetching the project news**, on Settings → General. On by
default; off means the request is never made.
**Closed holes in who can see what**
- A staff member limited to their own assigned clients could read the names of other clients out of
file details. Sharing means a file can reach somebody through one client while it was uploaded by
another, or while it is also shared with another. That is normal and the file is theirs to open —
but the uploader's name, the other recipient's name, and both their ID numbers were being sent
along with it, on the library list, the file's edit page, the details panel, the per-client file
list, and the matching API responses. A group holding none of their clients was named the same
way. The file list could also be filtered by uploader, which answered "does this client of yours
share files with that client of mine" without naming anybody.
- A staff member limited to their assigned clients could read other clients' names, and their IDs,
out of file details and the uploader filter. Reported by
[@Noorkhalel](https://github.com/Noorkhalel) (GHSA-whmp-p9hv-r7j7).
- Download links to external storage now last a minute instead of an hour. Previews keep the hour.
- Eight advisories in bundled dependencies, including an XSS bypass in the markdown renderer that
builds your email templates.
Those names are now left out for a limited staff member, and the uploader filter no longer answers
for a client they are not assigned to. Administrators and any unrestricted role see exactly what
they saw before.
**Fixed**
**Who this affected.** Only installations using the Client Manager role, or a custom role with
"Limit to assigned clients" switched on, and only where files are shared with more than one client
or through groups. No files, downloads or credentials were reachable this way — a file belonging
to a client outside the roster was refused before, and still is.
- A failed upload keeps its parts, so retrying it works instead of needing the whole file again.
- `projectsend:captcha-off` no longer claims success on an installation whose CAPTCHA keys are
supplied centrally, where it changed nothing.
Reported by [@Noorkhalel](https://github.com/Noorkhalel) (GHSA-whmp-p9hv-r7j7).
### Upgrade notes
- **Resuming an interrupted download from external storage more than a minute after it started now
fails.** Start it again from ProjectSend. Local-disk installations and zip bundles are unaffected.
- **If your temporary directory is on a small or separate volume, allow headroom for twice your
largest allowed upload.** Only while a file is being assembled, and nothing needs configuring.
Thanks to [@Noorkhalel](https://github.com/Noorkhalel), [@denkfabrik-li](https://github.com/denkfabrik-li)
and [@mehmedturk](https://github.com/mehmedturk) for reporting and fixing.
### Issues closed since 2.3.0
The summary above is what changed. This is the paper trail, for anyone who wants to read the
original report.
- [#1765](https://github.com/projectsend/projectsend/issues/1765) — Projectsend 2.2.1 thumbnail issue after file upload
- [#1771](https://github.com/projectsend/projectsend/issues/1771) — Permissions granted to the Client role are not applied to client accounts
## 2.3.0 — 1 September 2026
@@ -24,7 +24,10 @@ use App\Modules\Platform\Settings\Settings;
use App\Modules\Platform\Updates\LatestReleaseInfo;
use App\Modules\Platform\Updates\RunningCodeState;
use Illuminate\Foundation\Inspiring;
use App\Modules\Platform\Announcements\Events\ResolvingAnnouncement;
use App\Modules\Platform\Navigation\Events\ResolvingNavigationLinks;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
@@ -84,6 +87,19 @@ class HandleInertiaRequests extends Middleware
// ignore this and always show it.
'attribution' => app(Attribution::class)->visible(),
'capabilities' => $capabilities->enabledKeys(),
// Sidebar entries a package asked for. Shared rather than
// passed per page because the sidebar is on every page, and
// dispatched unconditionally so that with nothing listening
// the list is empty and the sidebar is exactly what it was.
// See ResolvingNavigationLinks for why core never learns what
// is in it.
'extra_nav_links' => $this->extraNavLinks($request),
// Shared rather than a dashboard prop, because it is shown in
// two places — the band on the dashboard and the icon beside
// the notification bell everywhere else — and "the same
// message" is the requirement. Two props would drift the day
// somebody edited one.
'announcement' => $this->announcement($request),
// Shared rather than passed by each page: the sign-in buttons,
// the registration form and the Connected accounts nav entry
// all need the same list, and a nav entry to a screen with
@@ -301,4 +317,43 @@ class HandleInertiaRequests extends Middleware
/** @var array<string, string> */
return app('translator')->getLoader()->load($locale, '*', '*');
}
/**
* @return list<array{title: string, url: string, external: bool, icon: string|null}>
*/
private function extraNavLinks(Request $request): array
{
$user = $request->user();
// Staff only, decided here rather than in each listener: these
// render in the administration area, and a client's portal shows
// their own files and nothing about the installation.
$event = new ResolvingNavigationLinks(isStaff: $user !== null && $user->isStaff());
if (! $event->isStaff) {
return [];
}
Event::dispatch($event);
return $event->links;
}
/**
* @return array{title: string, body: string, action_label: string|null, action_url: string|null, tone: string}|null
*/
private function announcement(Request $request): ?array
{
$user = $request->user();
if ($user === null) {
return null;
}
$event = new ResolvingAnnouncement(isStaff: $user->isStaff());
Event::dispatch($event);
return $event->announcement;
}
}
@@ -12,6 +12,7 @@ use App\Modules\Audit\ActivityLog;
use App\Modules\Audit\ActivityLogScope;
use App\Modules\Audit\ActivityPresenter;
use App\Modules\Audit\DashboardWidgetPreferences;
use Illuminate\Support\Facades\Event;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Delivery\FileDelivery;
@@ -31,32 +31,65 @@ use Symfony\Component\HttpFoundation\Response;
* path, BinaryFileResponse when PHP is streaming — each dropping the
* Content-Length passed here in favour of the range actually served.
*
* The two paths are not equally revocable, which is why the lifetimes
* below differ. Every local delivery method authorises one response and
* no more — nginx's X-Accel-Redirect, Apache's X-Sendfile, or PHP
* streaming the bytes itself: these bytes, now, to this request, and
* nothing that outlives it. A presigned URL is a bearer
* credential — whoever holds it can fetch the file without passing the
* caller's checks again, and it outlives them: a download cap that is
* spent in the meantime, an expires_at that falls in between, an
* assignment that is withdrawn. Nothing here can revoke one, so the only
* dial is how long it lasts.
*
* A download needs to survive being followed, which is a redirect and a
* request: a minute is generous. A preview is held by the player for as
* long as somebody watches, and each seek outside the buffer is a fresh
* Range request against the same URL, so it keeps the hour. That is the
* trade, stated rather than left in a single number.
*
* Callers of inline() must have established that the mime type is
* inline-safe first; PreviewKind is the allowlist, and the reason there
* is one.
*/
class StoredFileResponse
{
/**
* Long enough for a browser, a download manager or a queued transfer
* to follow the redirect and start the request. An object store
* checks the signature when the request arrives, not while it runs,
* so a transfer that begins inside this window finishes however long
* it takes.
*/
private const DOWNLOAD_LINK_SECONDS = 60;
/**
* A preview is watched, not fetched: the player holds this URL and
* issues a Range request every time somebody seeks past the buffer,
* so it has to outlive the viewing rather than the redirect.
*/
private const PREVIEW_LINK_SECONDS = 3600;
public function __construct(private readonly FileDelivery $delivery) {}
/** Shown in place — a preview. */
public function inline(File $file): Response|RedirectResponse
{
return $this->make($file, ContentDisposition::inline($file->original_name));
return $this->make($file, ContentDisposition::inline($file->original_name), self::PREVIEW_LINK_SECONDS);
}
/** Handed over — a download. */
public function attachment(File $file): Response|RedirectResponse
{
return $this->make($file, ContentDisposition::attachment($file->original_name));
return $this->make($file, ContentDisposition::attachment($file->original_name), self::DOWNLOAD_LINK_SECONDS);
}
private function make(File $file, string $disposition): Response|RedirectResponse
private function make(File $file, string $disposition, int $linkSeconds): Response|RedirectResponse
{
if ($file->disk !== 'files') {
$url = Storage::disk($file->disk)->temporaryUrl(
$file->path,
now()->addHour(),
now()->addSeconds($linkSeconds),
['ResponseContentDisposition' => $disposition],
);
+139 -73
View File
@@ -27,6 +27,12 @@ use Throwable;
*/
class LocalPartStore
{
/**
* Said twice, because a full temp volume can announce itself in the
* middle of the copy or only when the last buffer is flushed.
*/
private const WRITE_FAILED = 'Could not assemble the upload: writing to the temporary directory failed.';
/**
* The route name is a parameter because the same flow is mounted twice:
* once on the session-authenticated web routes for the browser, once on
@@ -140,9 +146,21 @@ class LocalPartStore
}
/**
* Stream-append parts in order onto the files disk, hashing as we
* go. Peak temp usage ≈ file size + one part (parts are unlinked
* as they are consumed).
* Stream-append parts in order onto the files disk, hashing as we go.
*
* The parts stay on disk until the assembled bytes are safely on the
* target disk. ChunkedUploadsController's completion lock promises that
* "a later retry still works", and everything that can fail after the
* concatenation — reopening the copy, a disk refusing the write, the
* File row itself — happens while the client has nothing but this
* session to retry with. Unlinking each part as it was consumed left
* listParts() empty, so every later complete() answered "Upload is
* incomplete: missing parts" for good.
*
* The cost is temp space: peak usage is the whole file twice over
* (every part, plus the assembled copy) rather than the file plus one
* part. Both are freed by the abort() below the moment the write lands,
* and by the failure path the moment it does not.
*
* @return array{path: string, disk: string, size: int, checksum: string}
*/
@@ -158,6 +176,93 @@ class LocalPartStore
}
$assembledPath = $this->directory($session).'/assembled';
try {
[$size, $checksum] = $this->concatenate($session, $parts, $assembledPath);
$readStream = fopen($assembledPath, 'rb');
if ($readStream === false) {
throw new RuntimeException('Could not reopen assembled file.');
}
$diskEvent = new ResolvingUploadDisk($session->user);
Event::dispatch($diskEvent);
$disk = $diskEvent->disk;
$written = Storage::disk($disk)->writeStream($targetPath, $readStream);
if (is_resource($readStream)) {
fclose($readStream);
}
// The disks are configured with 'throw' => false, so a refused
// write is a `false` return rather than an exception — and the
// caller goes on to record a File row for bytes that were never
// stored. Losing an upload silently is worse than failing it, and
// this is the only place that can tell the difference: a real
// instance of it was a GCS bucket rejecting the adapter's ACL,
// which looked exactly like a successful upload.
if ($written === false) {
// The reason is lost by the time it gets here — 'throw' => false
// means Flysystem swallowed the exception rather than passing it
// on — so log what was attempted. Which bucket it was is the
// difference between reading this as "my credentials expired"
// and "I typed the wrong bucket name", and only the log can say
// it: the message below is shown to whoever was uploading, which
// includes clients, and a bucket name is not theirs to see.
Log::error('Upload could not be written to storage.', [
'disk' => $disk,
'bucket' => config('filesystems.disks.'.$disk.'.bucket'),
'driver' => config('filesystems.disks.'.$disk.'.driver'),
'path' => $targetPath,
]);
throw new RuntimeException(
'Could not write the assembled upload to the "'.$disk.'" disk. '
.'Check the storage backend is reachable and its credentials are still valid.'
);
}
} catch (Throwable $failure) {
// The half-written copy belongs to this attempt and the next one
// makes its own; the parts belong to the client, and they are
// what a retry needs. Deleting the copy here is also the only
// thing that removes it at all on this path — it used to sit in
// the session directory until the sweeper came round.
FileSystem::delete($assembledPath);
throw $failure;
}
$this->abort($session);
return [
'path' => $targetPath,
'disk' => $disk,
'size' => $size,
'checksum' => $checksum,
];
}
/**
* Concatenate the parts into $assembledPath, returning the byte count
* and the sha256 of what was written.
*
* Every read and every write is checked. They were not, and while a
* failing fwrite on a full volume is loud in practice — Laravel's
* error handler turns the warning into an ErrorException — loud there
* means a 500 carrying a PHP message, where the disk-refused-the-write
* case a few lines above becomes a sentence the person uploading can
* act on. A short write arriving without a warning would be worse
* still: $size and the hash describe the buffer that was read, so an
* unchecked one yields a truncated file with a checksum matching bytes
* that were never stored.
*
* @param list<array{PartNumber: int, Size: int, ETag: string}> $parts
* @return array{0: int, 1: string}
*/
private function concatenate(UploadSession $session, array $parts, string $assembledPath): array
{
$out = fopen($assembledPath, 'wb');
if ($out === false) {
@@ -167,85 +272,46 @@ class LocalPartStore
$hash = hash_init('sha256');
$size = 0;
foreach ($parts as $part) {
$partPath = $this->partPath($session, $part['PartNumber']);
$in = fopen($partPath, 'rb');
try {
foreach ($parts as $part) {
$in = fopen($this->partPath($session, $part['PartNumber']), 'rb');
if ($in === false) {
fclose($out);
throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
}
while (! feof($in)) {
$buffer = fread($in, 1024 * 1024);
if ($buffer === false) {
break;
if ($in === false) {
throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
}
fwrite($out, $buffer);
hash_update($hash, $buffer);
$size += strlen($buffer);
try {
while (! feof($in)) {
$buffer = fread($in, 1024 * 1024);
if ($buffer === false) {
throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
}
if ($buffer !== '' && @fwrite($out, $buffer) !== strlen($buffer)) {
throw new RuntimeException(self::WRITE_FAILED);
}
hash_update($hash, $buffer);
$size += strlen($buffer);
}
} finally {
fclose($in);
}
}
} catch (Throwable $failure) {
fclose($out);
fclose($in);
unlink($partPath);
throw $failure;
}
fclose($out);
$readStream = fopen($assembledPath, 'rb');
if ($readStream === false) {
throw new RuntimeException('Could not reopen assembled file.');
// fclose flushes, so a volume that filled up on the last buffer
// fails here rather than in the loop.
if (! fclose($out)) {
throw new RuntimeException(self::WRITE_FAILED);
}
$diskEvent = new ResolvingUploadDisk($session->user);
Event::dispatch($diskEvent);
$disk = $diskEvent->disk;
$written = Storage::disk($disk)->writeStream($targetPath, $readStream);
if (is_resource($readStream)) {
fclose($readStream);
}
// The disks are configured with 'throw' => false, so a refused
// write is a `false` return rather than an exception — and the
// caller goes on to record a File row for bytes that were never
// stored. Losing an upload silently is worse than failing it, and
// this is the only place that can tell the difference: a real
// instance of it was a GCS bucket rejecting the adapter's ACL,
// which looked exactly like a successful upload.
if ($written === false) {
// The reason is lost by the time it gets here — 'throw' => false
// means Flysystem swallowed the exception rather than passing it
// on — so log what was attempted. Which bucket it was is the
// difference between reading this as "my credentials expired"
// and "I typed the wrong bucket name", and only the log can say
// it: the message below is shown to whoever was uploading, which
// includes clients, and a bucket name is not theirs to see.
Log::error('Upload could not be written to storage.', [
'disk' => $disk,
'bucket' => config('filesystems.disks.'.$disk.'.bucket'),
'driver' => config('filesystems.disks.'.$disk.'.driver'),
'path' => $targetPath,
]);
throw new RuntimeException(
'Could not write the assembled upload to the "'.$disk.'" disk. '
.'Check the storage backend is reachable and its credentials are still valid.'
);
}
$this->abort($session);
return [
'path' => $targetPath,
'disk' => $disk,
'size' => $size,
'checksum' => hash_final($hash),
];
return [$size, hash_final($hash)];
}
public function abort(UploadSession $session): void
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Announcements\Events;
/**
* A single message a package wants put in front of staff.
*
* Shown twice, from one source: a band across the top of the dashboard,
* and an icon beside the notification bell that opens the same words on
* every other page. One event rather than two because "the same message"
* is the requirement — two props would drift the day somebody edits one.
*
* Not a widget, on purpose. The widget grid is a closed list of keys that
* dashboard.tsx renders one by one, and each viewer arranges it — so a
* message that matters would sit wherever somebody happened to drag it,
* or under a fold, or switched off. A band above the grid is seen without
* competing with the columns for space.
*
* **Core knows nothing about what it says.** Title, body, the label on the
* button and where the button goes all come from the listener. The first
* caller is the hosted edition telling a free instance what a paid plan
* would give it, which is commercial copy belonging to one offering and
* has no place in the public repository.
*
* One at a time, deliberately. A dashboard that can accumulate banners
* accumulates them, and the second one is what teaches people to skip the
* first. A listener that finds one already set should leave it alone
* rather than overwrite it.
*/
class ResolvingAnnouncement
{
/**
* @var array{title: string, body: string, action_label: string|null, action_url: string|null, tone: string}|null
*/
public ?array $announcement = null;
public function __construct(
/** Whether the viewer is a staff account. */
public readonly bool $isStaff,
) {}
/**
* `tone` picks the accent the band is drawn in. Two values, because
* two is what the difference is worth: `info` for something worth
* knowing, `warning` for something worth acting on. Anything else
* falls back to `info` rather than rendering unstyled.
*/
public function show(string $title, string $body, ?string $actionLabel = null, ?string $actionUrl = null, string $tone = 'info'): void
{
if ($this->announcement !== null) {
return;
}
$this->announcement = [
'title' => $title,
'body' => $body,
'action_label' => $actionLabel,
'action_url' => $actionUrl,
'tone' => in_array($tone, ['info', 'warning'], true) ? $tone : 'info',
];
}
}
@@ -32,6 +32,24 @@ enum Capability: string
case EmailTransportConfigure = 'email.transport.configure';
case SystemUpdates = 'system.updates';
// Community-only — whether this installation may switch off the
// project news on its dashboard.
//
// Note what is Community-only: the *choice*, not the news. A managed
// instance still fetches and still shows it, and cannot be made to
// stop. That is the difference from SystemUpdates beside it, and it
// is worth stating because the two look alike and are opposites. An
// update notice is useless on a hosted tenant — they cannot act on
// it, the image is ours — so the check does not run at all there.
// News is the reverse: announcements about the product are exactly
// what a hosted customer should be told, and an administrator
// switching them off for everybody on that instance is not a
// preference we meant to hand over.
//
// A self-hosted operator keeps the switch, because there nobody else
// decides what their installation reaches out for.
case NewsConfigure = 'news.configure';
// Community-only — scheduled-task run history and failed-queue-job
// visibility. Cut on managed installations, where infrastructure
// monitoring happens outside this application; a transient failure
@@ -150,6 +168,7 @@ enum Capability: string
self::StorageConfigure,
self::EmailTransportConfigure,
self::SystemUpdates,
self::NewsConfigure,
self::SchedulerMonitoring,
self::CustomAssets => [Edition::Community],
@@ -45,6 +45,10 @@ class SystemSettingsController extends Controller
{
$canManageUpdates = $this->capabilities->has(Capability::SystemUpdates)
&& $request->user()?->can('manage_updates') === true;
// No permission beside it, unlike updates: turning the news card
// off is an ordinary settings change, and edit_settings already
// gates this whole screen.
$canConfigureNews = $this->capabilities->has(Capability::NewsConfigure);
return Inertia::render('system/settings/general', [
'site_name' => $this->settings->get(Setting::SiteName),
@@ -62,6 +66,15 @@ class SystemSettingsController extends Controller
'viewer_timezone' => $request->user()?->timezone,
'can_manage_updates' => $canManageUpdates,
'check_for_updates' => $canManageUpdates ? $this->settings->get(Setting::CheckForUpdates) : null,
// Its own capability, and deliberately not $canManageUpdates:
// the update block disappears on a managed instance because
// nobody there can act on it, while this one disappears
// because the news must keep arriving whether or not the
// instance's administrator would have chosen it. Null where
// the choice is not theirs, so the page renders no switch
// rather than a switch that would do nothing.
'can_configure_news' => $canConfigureNews,
'fetch_news' => $canConfigureNews ? $this->settings->get(Setting::FetchNews) : null,
'last_checked_at' => $canManageUpdates ? $this->lastCheckedAt()?->toIso8601String() : null,
'check_result' => $request->session()->get('update_check_result'),
]);
@@ -123,6 +136,10 @@ class SystemSettingsController extends Controller
{
$canManageUpdates = $this->capabilities->has(Capability::SystemUpdates)
&& $request->user()?->can('manage_updates') === true;
// No permission beside it, unlike updates: turning the news card
// off is an ordinary settings change, and edit_settings already
// gates this whole screen.
$canConfigureNews = $this->capabilities->has(Capability::NewsConfigure);
$rules = [
'site_name' => ['required', 'string', 'max:255'],
@@ -133,6 +150,10 @@ class SystemSettingsController extends Controller
// "follow APP_TIMEZONE", and only a fresh install has that.
'timezone' => ['sometimes', 'string', 'timezone', Rule::in($this->timezones->all())],
];
if ($canConfigureNews) {
$rules['fetch_news'] = ['sometimes', 'boolean'];
}
if ($canManageUpdates) {
// Omitting the field (any caller not sending it, not just this
// page's own form) leaves the current value alone rather than
@@ -156,6 +177,13 @@ class SystemSettingsController extends Controller
$this->settings->set(Setting::CheckForUpdates, $validated['check_for_updates']);
}
// Never read where the choice is not this installation's, so a
// hand-crafted PATCH cannot switch off the news on a managed
// instance any more than the absent checkbox could.
if ($canConfigureNews && array_key_exists('fetch_news', $validated)) {
$this->settings->set(Setting::FetchNews, $validated['fetch_news']);
}
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'general']);
return back();
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Navigation\Events;
/**
* Extra links a package wants in the sidebar.
*
* The sidebar is built from a hardcoded array in app-sidebar.tsx, which
* means a package could not contribute to it at all — the nav link was a
* separate manual edit every time a package grew a screen, and being
* manual it was forgotten. This is the seam that fixes that, in the shape
* the extension-points document settles on: core dispatches
* unconditionally, listeners add or do not, and with no listener the
* default (no extra links) holds.
*
* **Core deliberately learns nothing about what is added.** A link's
* label, its URL and the reason it exists all arrive from whoever
* registers it. That is not fastidiousness: the first caller is the
* hosted edition's link to its own customer portal, and a product URL
* belonging to one commercial offering has no business sitting in the
* public repository just because the sidebar happens to live here.
*
* Staff only, and enforced here rather than trusted to each listener:
* these appear in the administration area, and a client's portal shows
* only their own files.
*/
class ResolvingNavigationLinks
{
/**
* @var list<array{title: string, url: string, external: bool, icon: string|null}>
*/
public array $links = [];
public function __construct(
/** Whether the viewer is a staff account. Listeners that only make
* sense for staff should check this rather than assume. */
public readonly bool $isStaff,
) {}
/**
* `external` opens in a new tab and marks the link as leaving this
* installation — a link that navigates a person away from the app
* they are working in should say so before they click it, not after.
*/
public function add(string $title, string $url, bool $external = false, ?string $icon = null): void
{
$this->links[] = [
'title' => $title,
'url' => $url,
'external' => $external,
'icon' => $icon,
];
}
}
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Modules\Platform\News\Console;
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\Console\Command;
@@ -12,9 +14,13 @@ use Illuminate\Support\Facades\Http;
use Stevebauman\Purify\Facades\Purify;
/**
* Both editions — unlike CheckForUpdatesCommand, this isn't gated on any
* Capability: dashboard news is informational content, not an update
* action, so Cloud tenants see it too.
* Both editions, and on a managed instance not switchable off — unlike
* CheckForUpdatesCommand, which does not run there at all. Dashboard news
* is informational content rather than an update action, so hosted
* customers see it too, and see it whether their administrator would have
* chosen to or not. Capability::NewsConfigure is what a self-hosted
* installation holds and a managed one does not: the choice is the
* edition difference, not the news.
*
* The feed returns raw HTML in `content` (links, paragraphs) — sanitized
* here, once, before it's ever cached or sent to the frontend, so the
@@ -34,12 +40,36 @@ class FetchNewsCommand extends Command
public function __construct(
private readonly Settings $settings,
private readonly CapabilityRegistry $capabilities,
) {
parent::__construct();
}
public function handle(): int
{
// The setting only decides where the installation is allowed to
// make that choice. On a managed instance it is not: announcements
// about the product are what a hosted customer should be told, and
// one administrator switching them off for everybody on that
// instance is not a decision the platform hands over. So the news
// runs there regardless of what any row says — including a row
// left behind by an instance that used to be self-hosted.
//
// The opposite of the update check, which does not run on a
// managed instance at all because nobody there could act on it.
// The two look alike and point in different directions.
//
// Returns success rather than failure: a scheduled task that was
// asked not to run has not failed, and reporting it as a failure
// would put a red line in the scheduler history every night for
// an installation that is behaving exactly as configured.
if ($this->capabilities->has(Capability::NewsConfigure)
&& $this->settings->get(Setting::FetchNews) !== true) {
$this->info('The news feed is switched off for this installation.');
return self::SUCCESS;
}
$response = Http::withHeaders(['User-Agent' => 'ProjectSend'])
->timeout(10)
->get(self::FEED_URL);
+13
View File
@@ -249,6 +249,17 @@ enum Setting: string
// is allowed to tell the admin a newer release exists.
case CheckForUpdates = 'check_for_updates';
// Whether this installation fetches the project's news feed for the
// dashboard card. Its own key rather than riding on CheckForUpdates
// above, because they are two different wants: "do not tell me about
// releases" and "do not show me the project's news" are asked
// separately, and an installation with no outbound access at all
// wants both off while an ordinary one may want updates and no feed.
//
// On by default, so nothing changes for an installation that has
// never seen this switch.
case FetchNews = 'fetch_news';
// Cached result of the last update check — never written directly by
// a settings form, only by CheckForUpdatesCommand. Empty string means
// "no successful check yet" (fresh install, or checks disabled).
@@ -367,6 +378,7 @@ enum Setting: string
self::ClientsCanPreviewFiles,
self::PublicListingPreviewEnabled,
self::CheckForUpdates,
self::FetchNews,
self::ExpiredFilesAutoDeleteEnabled,
self::PublicCommentsEnabled,
self::CommentsGuestModeration,
@@ -433,6 +445,7 @@ enum Setting: string
self::OrphanFilesAutoDeleteEnabled => false,
self::CheckForUpdates,
self::FetchNews,
self::CommentsGuestModeration,
// On, so that an installation updating into these switches
// keeps the preview it already had rather than losing it to a
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Modules\Platform\Updates\Console;
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 App\Modules\Platform\Updates\CheckForUpdates;
@@ -32,12 +34,37 @@ class CheckForUpdatesCommand extends Command
public function __construct(
private readonly Settings $settings,
private readonly CheckForUpdates $check,
private readonly CapabilityRegistry $capabilities,
) {
parent::__construct();
}
public function handle(): int
{
// Ahead of the setting, and deliberately not a setting itself.
//
// A Setting says "the operator does not want this". The true
// statement on a managed installation is "there is nowhere for
// this to appear and nothing they could do about it": the
// dashboard's System card is gated on Capability::SystemUpdates
// (DashboardController), which is Community-only, so the answer
// this command fetches cannot be drawn on any screen — and the
// update UI is closed by the same capability, so it could not be
// acted on if it were. The image is chosen by whoever provisioned
// the instance.
//
// Encoding that as a preference would leave it switchable back on
// per tenant, which buys a nightly call to GitHub for a number
// nobody can see, and would leave the reason in a provisioning
// script rather than beside the code. A self-hosted installation
// holds the capability and loses nothing: its own setting below
// still decides.
if (! $this->capabilities->has(Capability::SystemUpdates)) {
$this->info('Update checks do not apply to this installation.');
return self::SUCCESS;
}
if ($this->settings->get(Setting::CheckForUpdates) !== true) {
$this->info('Update checks are disabled.');
+1 -1
View File
@@ -161,7 +161,7 @@ return [
|
*/
'version' => '2.3.0',
'version' => '2.4.0',
/*
|--------------------------------------------------------------------------
+5 -1
View File
@@ -2013,5 +2013,9 @@
"Edit file": "Edita el fitxer",
"Expires on": "Caduca el",
"Make this file public": "Fes públic aquest fitxer",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Aquest lloc encara no té cap pàgina pública configurada, així que no es veurà res fins que un administrador en configuri una."
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Aquest lloc encara no té cap pàgina pública configurada, així que no es veurà res fins que un administrador en configuri una.",
"Show ProjectSend news on the dashboard": "Mostra les novetats de ProjectSend al tauler",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Recupera els anuncis del projecte des de projectsend.org un cop al dia per a la targeta del tauler. Si ho desactives, aquesta instal·lació deixa de contactar amb projectsend.org per a novetats.",
"Announcement": "Avís",
"More": "Més"
}
+5 -1
View File
@@ -2013,5 +2013,9 @@
"Edit file": "Upravit soubor",
"Expires on": "Vyprší dne",
"Make this file public": "Zveřejnit tento soubor",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Tento web zatím nemá nastavenou veřejnou stránku, takže nebude nic vidět, dokud ji administrátor nenastaví."
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Tento web zatím nemá nastavenou veřejnou stránku, takže nebude nic vidět, dokud ji administrátor nenastaví.",
"Show ProjectSend news on the dashboard": "Zobrazovat novinky ProjectSendu v přehledu",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Jednou denně načte oznámení projektu z projectsend.org pro kartu v přehledu. Když to vypnete, tato instalace se kvůli novinkám na projectsend.org už vůbec nepřipojí.",
"Announcement": "Oznámení",
"More": "Další"
}
+5 -1
View File
@@ -2013,5 +2013,9 @@
"Edit file": "Datei bearbeiten",
"Expires on": "Läuft ab am",
"Make this file public": "Diese Datei öffentlich machen",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Für diese Website ist noch keine öffentliche Seite eingerichtet, daher ist nichts sichtbar, bis ein Administrator eine einrichtet."
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Für diese Website ist noch keine öffentliche Seite eingerichtet, daher ist nichts sichtbar, bis ein Administrator eine einrichtet.",
"Show ProjectSend news on the dashboard": "ProjectSend-Neuigkeiten in der Übersicht anzeigen",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Ruft einmal täglich Projektankündigungen von projectsend.org für die Karte in der Übersicht ab. Ausgeschaltet nimmt diese Installation für Neuigkeiten überhaupt keine Verbindung zu projectsend.org mehr auf.",
"Announcement": "Ankündigung",
"More": "Mehr"
}
+5 -1
View File
@@ -2013,5 +2013,9 @@
"Edit file": "Editar archivo",
"Expires on": "Vence el",
"Make this file public": "Hacer público este archivo",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Este sitio todavía no tiene una página pública configurada, así que no se verá nada hasta que un administrador la configure."
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Este sitio todavía no tiene una página pública configurada, así que no se verá nada hasta que un administrador la configure.",
"Show ProjectSend news on the dashboard": "Mostrar las noticias de ProjectSend en el panel de control",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Trae los anuncios del proyecto desde projectsend.org una vez al día para la tarjeta del panel. Si lo desactivas, esta instalación deja de contactar a projectsend.org por noticias.",
"Announcement": "Aviso",
"More": "Más"
}
+5 -1
View File
@@ -2013,5 +2013,9 @@
"Edit file": "Modifier le fichier",
"Expires on": "Expire le",
"Make this file public": "Rendre ce fichier public",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Ce site n'a pas encore de page publique configurée, donc rien ne sera visible tant qu'un administrateur n'en aura pas configuré une."
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Ce site n'a pas encore de page publique configurée, donc rien ne sera visible tant qu'un administrateur n'en aura pas configuré une.",
"Show ProjectSend news on the dashboard": "Afficher les actualités ProjectSend sur le tableau de bord",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Récupère une fois par jour les annonces du projet depuis projectsend.org pour la carte du tableau de bord. Désactivé, cette installation ne contacte plus du tout projectsend.org pour les actualités.",
"Announcement": "Annonce",
"More": "Plus"
}
+5 -1
View File
@@ -2013,5 +2013,9 @@
"Edit file": "Sunting berkas",
"Expires on": "Kedaluwarsa pada",
"Make this file public": "Jadikan berkas ini publik",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Situs ini belum punya halaman publik, jadi tidak ada yang terlihat sampai administrator menyiapkannya."
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Situs ini belum punya halaman publik, jadi tidak ada yang terlihat sampai administrator menyiapkannya.",
"Show ProjectSend news on the dashboard": "Tampilkan kabar terbaru ProjectSend di dasbor",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Mengambil pengumuman proyek dari projectsend.org sekali sehari untuk kartu di dasbor. Jika dimatikan, instalasi ini sama sekali tidak lagi menghubungi projectsend.org untuk kabar terbaru.",
"Announcement": "Pengumuman",
"More": "Lainnya"
}
+5 -1
View File
@@ -2013,5 +2013,9 @@
"Edit file": "Modifica file",
"Expires on": "Scade il",
"Make this file public": "Rendi pubblico questo file",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Questo sito non ha ancora una pagina pubblica, quindi non sarà visibile nulla finché un amministratore non ne configura una."
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Questo sito non ha ancora una pagina pubblica, quindi non sarà visibile nulla finché un amministratore non ne configura una.",
"Show ProjectSend news on the dashboard": "Mostra le novità di ProjectSend nel pannello",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Recupera gli annunci del progetto da projectsend.org una volta al giorno per la scheda del pannello. Disattivandolo, questa installazione smette del tutto di contattare projectsend.org per le novità.",
"Announcement": "Avviso",
"More": "Altro"
}
+5 -1
View File
@@ -2013,5 +2013,9 @@
"Edit file": "ファイルを編集",
"Expires on": "有効期限",
"Make this file public": "このファイルを公開する",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "このサイトにはまだ公開ページが設定されていないため、管理者が設定するまで何も表示されません。"
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "このサイトにはまだ公開ページが設定されていないため、管理者が設定するまで何も表示されません。",
"Show ProjectSend news on the dashboard": "ダッシュボードに ProjectSend のお知らせを表示する",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "ダッシュボードのカード用に、プロジェクトのお知らせを projectsend.org から1日1回取得します。オフにすると、このインストールはお知らせのために projectsend.org へ接続しなくなります。",
"Announcement": "お知らせ",
"More": "その他"
}
+5 -1
View File
@@ -2013,5 +2013,9 @@
"Edit file": "Bestand bewerken",
"Expires on": "Verloopt op",
"Make this file public": "Dit bestand openbaar maken",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Deze site heeft nog geen openbare pagina, dus er is niets zichtbaar totdat een beheerder er een instelt."
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Deze site heeft nog geen openbare pagina, dus er is niets zichtbaar totdat een beheerder er een instelt.",
"Show ProjectSend news on the dashboard": "ProjectSend-nieuws op het overzicht tonen",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Haalt eens per dag projectaankondigingen op van projectsend.org voor de kaart op het overzicht. Uitgeschakeld neemt deze installatie voor nieuws helemaal geen contact meer op met projectsend.org.",
"Announcement": "Mededeling",
"More": "Meer"
}
+5 -1
View File
@@ -2013,5 +2013,9 @@
"Edit file": "Edytuj plik",
"Expires on": "Wygasa",
"Make this file public": "Ustaw ten plik jako publiczny",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Ta witryna nie ma jeszcze skonfigurowanej strony publicznej, więc nic nie będzie widoczne, dopóki administrator jej nie ustawi."
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Ta witryna nie ma jeszcze skonfigurowanej strony publicznej, więc nic nie będzie widoczne, dopóki administrator jej nie ustawi.",
"Show ProjectSend news on the dashboard": "Pokazuj aktualności ProjectSend na pulpicie",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Raz dziennie pobiera ogłoszenia projektu z projectsend.org na kartę pulpitu. Po wyłączeniu ta instalacja w ogóle przestaje łączyć się z projectsend.org po aktualności.",
"Announcement": "Ogłoszenie",
"More": "Więcej"
}
+5 -1
View File
@@ -2013,5 +2013,9 @@
"Edit file": "Editar arquivo",
"Expires on": "Expira em",
"Make this file public": "Tornar este arquivo público",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Este site ainda não tem uma página pública configurada, então nada ficará visível até que um administrador configure uma."
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Este site ainda não tem uma página pública configurada, então nada ficará visível até que um administrador configure uma.",
"Show ProjectSend news on the dashboard": "Mostrar novidades do ProjectSend no painel",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Busca os anúncios do projeto em projectsend.org uma vez por dia para o cartão do painel. Se você desativar, esta instalação para de contatar o projectsend.org por novidades.",
"Announcement": "Aviso",
"More": "Mais"
}
+5 -1
View File
@@ -2013,5 +2013,9 @@
"Edit file": "Редактировать файл",
"Expires on": "Срок действия до",
"Make this file public": "Сделать этот файл публичным",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "На этом сайте ещё не настроена публичная страница, поэтому ничего не будет видно, пока администратор её не настроит."
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "На этом сайте ещё не настроена публичная страница, поэтому ничего не будет видно, пока администратор её не настроит.",
"Show ProjectSend news on the dashboard": "Показывать новости ProjectSend на панели",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Раз в день загружает анонсы проекта с projectsend.org для карточки на панели. Если выключить, эта установка вообще перестанет обращаться к projectsend.org за новостями.",
"Announcement": "Объявление",
"More": "Ещё"
}
+5 -1
View File
@@ -2013,5 +2013,9 @@
"Edit file": "Hariri faili",
"Expires on": "Litaisha muda tarehe",
"Make this file public": "Fanya faili hili liwe la umma",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Tovuti hii bado haina ukurasa wa umma, kwa hivyo hakuna kitakachoonekana hadi msimamizi aweke mmoja."
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Tovuti hii bado haina ukurasa wa umma, kwa hivyo hakuna kitakachoonekana hadi msimamizi aweke mmoja.",
"Show ProjectSend news on the dashboard": "Onyesha habari za ProjectSend kwenye dashibodi",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Huleta matangazo ya mradi kutoka projectsend.org mara moja kwa siku kwa ajili ya kadi ya dashibodi. Ukiizima, usakinishaji huu hautawasiliana kabisa na projectsend.org kwa habari.",
"Announcement": "Tangazo",
"More": "Zaidi"
}
+5 -1
View File
@@ -2013,5 +2013,9 @@
"Edit file": "Dosyayı düzenle",
"Expires on": "Sona erme tarihi",
"Make this file public": "Bu dosyayı herkese açık yap",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Bu sitede henüz herkese açık bir sayfa ayarlanmamış, bu yüzden bir yönetici ayarlayana kadar hiçbir şey görünmeyecek."
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Bu sitede henüz herkese açık bir sayfa ayarlanmamış, bu yüzden bir yönetici ayarlayana kadar hiçbir şey görünmeyecek.",
"Show ProjectSend news on the dashboard": "ProjectSend haberlerini panelde göster",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Panel kartı için proje duyurularını günde bir kez projectsend.org adresinden alır. Kapatıldığında bu kurulum haberler için projectsend.org ile hiç bağlantı kurmaz.",
"Announcement": "Duyuru",
"More": "Daha fazla"
}
+5 -1
View File
@@ -2013,5 +2013,9 @@
"Edit file": "Sửa tệp",
"Expires on": "Hết hạn vào",
"Make this file public": "Công khai tệp này",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Trang web này chưa thiết lập trang công khai, nên sẽ không có gì hiển thị cho đến khi quản trị viên thiết lập."
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Trang web này chưa thiết lập trang công khai, nên sẽ không có gì hiển thị cho đến khi quản trị viên thiết lập.",
"Show ProjectSend news on the dashboard": "Hiển thị tin tức ProjectSend trên bảng điều khiển",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Tải thông báo của dự án từ projectsend.org mỗi ngày một lần cho thẻ trên bảng điều khiển. Khi tắt, bản cài đặt này sẽ hoàn toàn không liên hệ với projectsend.org để lấy tin tức.",
"Announcement": "Thông báo",
"More": "Thêm"
}
+5 -1
View File
@@ -2013,5 +2013,9 @@
"Edit file": "编辑文件",
"Expires on": "到期日",
"Make this file public": "将此文件设为公开",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "本站尚未设置公开页面,因此在管理员设置之前不会显示任何内容。"
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "本站尚未设置公开页面,因此在管理员设置之前不会显示任何内容。",
"Show ProjectSend news on the dashboard": "在仪表板上显示 ProjectSend 动态",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "每天一次从 projectsend.org 获取项目公告,用于仪表板卡片。关闭后,本安装将完全不再因动态而连接 projectsend.org。",
"Announcement": "公告",
"More": "更多"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

+5 -16
View File
@@ -1,22 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-9 0 252.3 252.3">
<!--
The ProjectSend mark: the swoosh in the project gradient (violet to
blue). One shape for the project, one gradient per edition — the
hosted portal draws the same vector in the Cloud gradient, so the
colour is what tells the tabs apart. Same path as
resources/js/components/app-logo-icon.tsx; change both together.
Padded to a square rather than the artwork's own bounds, so the mark
fills whatever square slot a browser puts an icon in — a tab, a
bookmark bar, a pinned shortcut — instead of being letterboxed.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.26482 252.25172">
<defs>
<linearGradient id="ps-icon-gradient" gradientUnits="userSpaceOnUse" x1="31.5" y1="211" x2="197.5" y2="50.8">
<stop offset="0.17" stop-color="#5219B3"/>
<stop offset="0.81" stop-color="#3072BF"/>
<linearGradient id="ps-icon-gradient" gradientUnits="userSpaceOnUse" x1="31.5263" y1="211.0108" x2="197.4637" y2="50.766899">
<stop offset="0.1675" stop-color="#5219B3"/>
<stop offset="0.8128" stop-color="#3072BF"/>
</linearGradient>
</defs>
<g transform="translate(-2.5,-2.13)">
<g transform="translate(-2.5,-2.1253469)">
<path fill="url(#ps-icon-gradient)" d="m 233.21,158.034 c -2.53,0 -4.992,0 -7.522,0 -38.087,-1.299 -74.943,-8.889 -110.774,-21.676 C 87.015,126.375 60.621,113.451 37.441,94.647 29.783,88.493 22.876,81.587 16.585,73.997 15.286,72.356 15.149,71.604 17.2,70.51 58.092,48.285 98.983,26.062 139.873,3.839 c 6.633,-3.556 12.787,-1.504 16.684,5.47 19.283,35.352 38.497,70.772 57.78,106.124 7.043,12.992 14.154,25.984 21.197,38.976 1.916,3.556 1.916,3.556 -2.324,3.625 z M 3.799,130.545 c 21.061,38.702 42.121,77.337 63.182,115.971 4.513,8.274 10.325,9.915 18.667,5.402 48.002,-26.121 95.936,-52.241 143.869,-78.362 0.957,-0.479 2.051,-0.752 2.735,-1.846 C 153.002,171.573 75.939,160.495 2.5,127.947 c 0.547,1.026 0.889,1.846 1.299,2.598 z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+114
View File
@@ -0,0 +1,114 @@
import { type SharedData } from '@/types';
import { usePage } from '@inertiajs/react';
import { ExternalLink, Megaphone } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { useTranslation } from '@/hooks/use-translation';
export interface Announcement {
title: string;
body: string;
action_label: string | null;
action_url: string | null;
tone: 'info' | 'warning' | string;
}
/**
* One message, shown two ways, from one shared prop — see
* ResolvingAnnouncement. The band is the dashboard; the icon beside the
* notification bell carries the same words to every other page, so
* somebody who never opens the dashboard still meets it once.
*
* Both live in this file deliberately. They have to say the same thing,
* and the reliable way to keep two renderings of one message identical is
* for them to share the component that renders it.
*
* Nothing here knows what it is saying. Title, body and button all arrive
* from whatever listened.
*
* Coloured enough to be read and not enough to alarm: a tinted left edge
* and a matching wash, rather than a saturated block. The first caller
* tells a hosted customer their plan has limits and a bigger one exists,
* which is worth noticing and not worth interrupting for — so it must not
* look like an outage. Both palettes are declared per tone rather than
* derived, so the dark variant is a deliberate colour rather than
* whatever the light one happens to become.
*/
const TONES: Record<string, string> = {
info: 'border-l-sky-500 bg-sky-50 dark:bg-sky-950/40',
warning: 'border-l-amber-500 bg-amber-50 dark:bg-amber-950/40',
};
const DOT_TONES: Record<string, string> = {
info: 'bg-sky-500',
warning: 'bg-amber-500',
};
/** The words, and the button if there is one. Shared by both surfaces. */
function AnnouncementBody({ announcement, compact = false }: { announcement: Announcement; compact?: boolean }) {
return (
<>
<div className="min-w-0">
<p className="text-sm font-semibold">{announcement.title}</p>
<p className="text-muted-foreground mt-1 text-sm">{announcement.body}</p>
</div>
{announcement.action_label && announcement.action_url && (
<Button asChild variant="outline" size="sm" className={compact ? 'w-full bg-transparent' : 'shrink-0 bg-transparent'}>
{/* Always a new tab: the destination is outside this
installation, and taking somebody out of the app
they are working in is not what this should do. */}
<a href={announcement.action_url} target="_blank" rel="noopener noreferrer">
{announcement.action_label}
<ExternalLink className="size-3.5" />
</a>
</Button>
)}
</>
);
}
/** The dashboard band. */
export function AnnouncementBand({ announcement }: { announcement: Announcement }) {
const tone = TONES[announcement.tone] ?? TONES.info;
return (
<div className={`mb-6 flex flex-col gap-3 rounded-lg border border-l-4 p-4 sm:flex-row sm:items-center sm:justify-between ${tone}`}>
<AnnouncementBody announcement={announcement} />
</div>
);
}
/**
* The header icon, beside the notification bell.
*
* Same shape as UpdateAvailableIcon next to it: absent entirely when
* there is nothing to say, rather than a dead control. The dot marks it
* without a count — there is only ever one of these, and "1" on a badge
* would invite somebody to look for the second.
*/
export function AnnouncementIcon() {
const { t } = useTranslation();
const { announcement } = usePage<SharedData>().props;
if (!announcement) {
return null;
}
const dot = DOT_TONES[announcement.tone] ?? DOT_TONES.info;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative" aria-label={announcement.title || t('Announcement')}>
<Megaphone className="size-5" />
<span className={`absolute top-0.5 right-0.5 size-2.5 rounded-full ${dot}`} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="flex w-80 flex-col gap-3 p-4">
<AnnouncementBody announcement={announcement} compact />
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -1,3 +1,4 @@
import { AnnouncementIcon } from '@/components/announcement';
import { AppearanceSwitcher } from '@/components/appearance-switcher';
import { Breadcrumbs } from '@/components/breadcrumbs';
import { IconLocaleSwitcher } from '@/components/locale-switcher';
@@ -15,6 +16,7 @@ export function AppSidebarHeader({ breadcrumbs = [] }: { breadcrumbs?: Breadcrum
</div>
<div className="ml-auto flex items-center gap-1">
<UpdateAvailableIcon />
<AnnouncementIcon />
<NotificationBell />
<AppearanceSwitcher />
<IconLocaleSwitcher />
+18 -1
View File
@@ -32,7 +32,7 @@ import AppLogo from './app-logo';
export function AppSidebar() {
const { t } = useTranslation();
const { auth, capabilities, pending, version } = usePage<SharedData>().props;
const { auth, capabilities, extra_nav_links, pending, version } = usePage<SharedData>().props;
// Modules (Files, Clients, Groups…) add their own groups here as
// they land, mirroring v1's grouped admin menu.
@@ -262,6 +262,23 @@ export function AppSidebar() {
});
}
// Contributed by whatever is installed — see ResolvingNavigationLinks.
// Their own group at the end rather than mixed into Administration:
// these leave the installation, and a link that takes somebody out of
// the app should not sit between two that do not. Empty on every
// installation with nothing listening, and the group disappears with
// it rather than rendering a heading over nothing.
if (extra_nav_links.length > 0) {
groups.push({
title: t('More'),
items: extra_nav_links.map((link) => ({
title: link.title,
url: link.url,
external: link.external,
})),
});
}
return (
<Sidebar collapsible="icon" variant="inset">
<SidebarHeader>
+26 -6
View File
@@ -12,7 +12,7 @@ import {
} from '@/components/ui/sidebar';
import { type NavGroup } from '@/types';
import { Link, usePage } from '@inertiajs/react';
import { ChevronRight } from 'lucide-react';
import { ChevronRight, ExternalLink } from 'lucide-react';
export function NavMain({ groups = [] }: { groups: NavGroup[] }) {
const page = usePage();
@@ -58,11 +58,31 @@ export function NavMain({ groups = [] }: { groups: NavGroup[] }) {
</Collapsible>
) : (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild isActive={isActive(item.url)} tooltip={item.title}>
<Link href={item.url ?? '#'}>
{item.icon && <item.icon />}
<span>{item.title}</span>
</Link>
<SidebarMenuButton
asChild
isActive={item.external ? false : isActive(item.url)}
tooltip={item.title}
>
{/* An external destination is a plain
anchor, never an Inertia <Link>:
Link expects a page component back
and another origin will not give it
one, so it fails without saying so.
It is also never "active" — nothing
outside this app is the page you
are on. */}
{item.external ? (
<a href={item.url ?? '#'} target="_blank" rel="noopener noreferrer">
{item.icon && <item.icon />}
<span>{item.title}</span>
<ExternalLink className="ml-auto size-3.5 opacity-60" />
</a>
) : (
<Link href={item.url ?? '#'}>
{item.icon && <item.icon />}
<span>{item.title}</span>
</Link>
)}
</SidebarMenuButton>
{item.badge !== undefined && item.badge > 0 && (
<SidebarMenuBadge className="bg-primary text-primary-foreground peer-hover/menu-button:text-primary-foreground peer-data-[active=true]/menu-button:text-primary-foreground rounded-full">
+8 -1
View File
@@ -28,6 +28,7 @@ import { TopClientsWidget, type TopClient } from '@/components/dashboard-widgets
import { TransfersRangeControls, TransfersWidget, type TransferPoint, type TransfersRange } from '@/components/dashboard-widgets/transfers-widget';
import { WidgetBox } from '@/components/dashboard-widgets/widget-box';
import { WidgetsDialog } from '@/components/dashboard-widgets/widgets-dialog';
import { AnnouncementBand } from '@/components/announcement';
import Heading from '@/components/heading';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -110,7 +111,7 @@ export default function Dashboard({
dashboard_columns,
}: DashboardProps) {
const { t } = useTranslation();
const { update_notice } = usePage<SharedData>().props;
const { announcement, update_notice } = usePage<SharedData>().props;
const [releaseDialogOpen, setReleaseDialogOpen] = useState(false);
const [widgetsDialogOpen, setWidgetsDialogOpen] = useState(false);
const [layout, setLayout] = useState<WidgetLayout>(widget_layout);
@@ -336,6 +337,12 @@ export default function Dashboard({
<Head title={t('Dashboard')} />
<div className="space-y-6 px-4 py-6">
{/* Above the heading, not inside the grid: whoever asked
for this wants it read, and the grid is arranged by
each viewer. Read from shared props, the same source
the header icon uses, so the two cannot disagree. */}
{announcement && <AnnouncementBand announcement={announcement} />}
<div className="flex flex-wrap items-center justify-between gap-3">
<Heading title={t('Dashboard')} description={t('An overview of this installation')} />
<Button variant="outline" size="sm" onClick={() => setWidgetsDialogOpen(true)}>
@@ -23,6 +23,8 @@ interface SystemSettingsProps {
viewer_timezone: string | null;
can_manage_updates: boolean;
check_for_updates: boolean | null;
fetch_news: boolean | null;
can_configure_news: boolean;
/** When the release feed was last asked, by anybody. Null until it has been. */
last_checked_at: string | null;
/** The answer to a "check now" press, for the one render after it. */
@@ -36,6 +38,8 @@ export default function SystemSettings({
viewer_timezone,
can_manage_updates,
check_for_updates,
fetch_news,
can_configure_news,
last_checked_at,
check_result,
}: SystemSettingsProps) {
@@ -75,6 +79,7 @@ export default function SystemSettings({
site_name: site_name,
timezone: timezone,
check_for_updates: check_for_updates ?? false,
fetch_news: fetch_news ?? true,
});
const submit: FormEventHandler = (e) => {
@@ -132,6 +137,30 @@ export default function SystemSettings({
<InputError className="mt-2" message={errors.timezone} />
</div>
{/* Its own capability, not can_manage_updates: that block
disappears on a managed installation because nobody
there can act on an update notice, while this one
disappears because the news has to keep arriving
whether or not that installation's administrator
would have chosen it. */}
{can_configure_news && (
<div className="grid gap-2">
<div className="flex items-center gap-2">
<Checkbox
id="fetch_news"
checked={data.fetch_news}
onCheckedChange={(checked) => setData('fetch_news', checked === true)}
/>
<Label htmlFor="fetch_news">{t('Show ProjectSend news on the dashboard')}</Label>
</div>
<p className="text-muted-foreground text-sm">
{t(
'Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.',
)}
</p>
</div>
)}
{can_manage_updates && (
<div className="grid gap-2">
<div className="flex items-center gap-2">
+29
View File
@@ -1,3 +1,4 @@
import { type Announcement } from '@/components/announcement';
import { type InstallKind } from '@/components/update-instructions';
import { LucideIcon } from 'lucide-react';
@@ -23,6 +24,12 @@ export interface NavItem {
isActive?: boolean;
items?: NavItem[];
badge?: number;
/**
* Leaves this installation. Rendered as a plain anchor opening in a
* new tab rather than an Inertia <Link>, which would try to fetch a
* page component from another origin and fail silently.
*/
external?: boolean;
}
export type Edition = 'community' | 'cloud';
@@ -32,6 +39,7 @@ export type Capability =
| 'storage.configure'
| 'email.transport.configure'
| 'system.updates'
| 'news.configure'
| 'scheduler.monitoring'
| 'custom_assets.manage'
| 'branding.customize'
@@ -39,6 +47,19 @@ export type Capability =
| 'captcha.configure'
| 'captcha.managed_keys';
/**
* A sidebar entry contributed by a package — see
* ResolvingNavigationLinks. Staff-only and already filtered server-side,
* so the sidebar renders these without re-deciding who may see them.
*/
export interface ExtraNavLink {
title: string;
url: string;
/** Opens in a new tab and shows that it leaves this installation. */
external: boolean;
icon: string | null;
}
export interface SocialLoginProvider {
provider: string;
label: string;
@@ -90,6 +111,14 @@ export interface SharedData {
*/
attribution: boolean;
capabilities: Capability[];
/** Sidebar entries contributed by packages, already staff-filtered. */
extra_nav_links: ExtraNavLink[];
/**
* One message to put in front of staff, or null. Rendered as a band
* on the dashboard and behind the header icon everywhere else — see
* ResolvingAnnouncement. Shared so both say the same thing.
*/
announcement: Announcement | null;
/** Identity providers that are switched on and fully configured. */
social_login: SocialLoginProvider[];
/** The CAPTCHA in force, or null when this installation has none. */
@@ -329,6 +329,61 @@ test('a storage backend that refuses the write fails the upload instead of recor
expect(File::query()->count())->toBe($before);
});
// What survives a completion that failed. The lock in complete() promises
// the client may try again once whatever went wrong is fixed -- "the
// lock's TTL releases the claim if a completion dies mid-flight, so a
// later retry still works" -- and a retry has nothing to work from but
// the parts.
test('a refused write leaves the parts for the retry the lock promises', function () {
$this->actingAs($this->admin);
$sessionId = createSession(11, 'assembled.txt');
putPart($sessionId, 1, 'hello-');
putPart($sessionId, 2, 'world');
$refusing = Mockery::mock(Illuminate\Contracts\Filesystem\Filesystem::class);
$refusing->shouldReceive('writeStream')->once()->andReturnFalse();
Storage::set('files', $refusing);
$this->postJson("/uploads/{$sessionId}/complete")->assertStatus(422);
// Both parts are still there, and the half-written copy is not.
expect($this->getJson("/uploads/{$sessionId}/parts")->json())->toHaveCount(2)
->and(file_exists(partsRoot().'/'.$sessionId.'/assembled'))->toBeFalse();
// The operator fixes the bucket; the same session completes.
Storage::fake('files');
$this->postJson("/uploads/{$sessionId}/complete")->assertOk();
$file = File::query()->latest('id')->firstOrFail();
expect(Storage::disk('files')->get($file->path))->toBe('hello-world')
->and($file->size)->toBe(11);
});
test('a temporary directory that cannot be written fails the upload, and says so', function () {
// /dev/full accepts an open and refuses every write with ENOSPC, which
// is the failure this guards against without needing a full volume.
// Unchecked, the byte count and the checksum describe what was read
// rather than what landed; checked, it reads like the other storage
// failure a few lines below it in the same method.
$this->actingAs($this->admin);
$sessionId = createSession(11, 'assembled.txt');
putPart($sessionId, 1, 'hello-');
putPart($sessionId, 2, 'world');
symlink('/dev/full', partsRoot().'/'.$sessionId.'/assembled');
$this->postJson("/uploads/{$sessionId}/complete")
->assertStatus(422)
->assertJsonPath('errors.parts.0', fn (string $message): bool => str_contains($message, 'Could not assemble the upload'));
// Nothing recorded, and the parts are still the client's to retry with.
expect(File::query()->count())->toBe(0)
->and($this->getJson("/uploads/{$sessionId}/parts")->json())->toHaveCount(2);
})->skip(! file_exists('/dev/full'), 'needs /dev/full, which only exists on Linux');
// A chunked upload is two requests, and store()'s rule only ever sees the
// first one. Delete the folder while the bytes are in flight and the
// session still names it -- the version of this that nobody can ask for
@@ -3,7 +3,9 @@
declare(strict_types=1);
use App\Models\User;
use App\Modules\Files\Models\File;
use App\Support\ContentDisposition;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
@@ -61,3 +63,70 @@ test('downloads of files with non-ascii names send both header forms on the wire
->toContain('attachment; filename="')
->toContain("filename*=utf-8''".rawurlencode('año contable — resumen.pdf'));
});
/*
|--------------------------------------------------------------------------
| How long a presigned URL stays usable
|--------------------------------------------------------------------------
|
| On the local disk a delivery is an X-Accel-Redirect: it authorises one
| response, to one request. On external storage it is a presigned URL,
| which is a bearer credential -- forwardable, and valid whatever happens
| to the file or the caller's checks in the meantime. Nothing can revoke
| one, so its lifetime is the only dial there is.
|
*/
test('a download link outlives the redirect and not much else', function () {
$seen = [];
Storage::fake('files_external');
Storage::disk('files_external')->buildTemporaryUrlsUsing(
function (string $path, $expiration, array $options) use (&$seen): string {
$seen[] = $expiration;
return 'https://storage.example.test/'.$path;
}
);
$file = uploadDocumentFile($this->admin, 'report.pdf');
$file->update(['disk' => 'files_external']);
$this->actingAs($this->admin)->get("/files/{$file->id}/download")->assertRedirect();
expect($seen)->toHaveCount(1)
->and($seen[0]->getTimestamp())->toBeLessThanOrEqual(now()->addSeconds(60)->getTimestamp())
->and($seen[0]->getTimestamp())->toBeGreaterThan(now()->addSeconds(30)->getTimestamp());
});
test('a preview link lasts as long as somebody might watch', function () {
// The other half of the trade: a player holds this URL and asks it for
// ranges every time the viewer seeks past the buffer, so a minute would
// break playback of anything longer than a minute.
$seen = [];
Storage::fake('files_external');
Storage::disk('files_external')->buildTemporaryUrlsUsing(
function (string $path, $expiration, array $options) use (&$seen): string {
$seen[] = $expiration;
return 'https://storage.example.test/'.$path;
}
);
// Uploaded rather than factory-made, so it is a real previewable file;
// FilePreviewTest's helper is private to that file (Pest globals).
$this->actingAs($this->admin)->post('/files', [
'file' => UploadedFile::fake()->create('clip.mp4', 16, 'video/mp4'),
'name' => '',
'description' => '',
]);
$file = File::query()->latest('id')->firstOrFail();
$file->update(['disk' => 'files_external']);
$this->actingAs($this->admin)->get("/files/{$file->id}/preview")->assertRedirect();
expect($seen)->toHaveCount(1)
->and($seen[0]->getTimestamp())->toBeGreaterThan(now()->addMinutes(50)->getTimestamp());
});
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Platform\Announcements\Events\ResolvingAnnouncement;
use App\Modules\Platform\Navigation\Events\ResolvingNavigationLinks;
use Illuminate\Support\Facades\Event;
use Inertia\Testing\AssertableInertia;
beforeEach(function () {
$this->admin = User::factory()->create();
});
/*
|--------------------------------------------------------------------------
| Two seams a package fills, and core does not
|--------------------------------------------------------------------------
|
| Both are dispatched unconditionally, and with nothing listening the
| documented default holds — no links, no callout. That is what makes them
| safe to add to a community installation that will never have a listener.
*/
test('with nothing listening the dashboard is exactly what it was', function () {
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page->where('announcement', null),
);
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page->where('extra_nav_links', []),
);
});
test('a listener can put a message in front of staff', function () {
Event::listen(ResolvingAnnouncement::class, function (ResolvingAnnouncement $event): void {
$event->show('Heads up', 'Something worth reading.', 'Do the thing', 'https://example.test/', 'warning');
});
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page
->where('announcement.title', 'Heads up')
->where('announcement.action_url', 'https://example.test/')
->where('announcement.tone', 'warning'),
);
});
test('a listener can add a sidebar link', function () {
Event::listen(ResolvingNavigationLinks::class, function (ResolvingNavigationLinks $event): void {
$event->add('Somewhere else', 'https://example.test/', external: true);
});
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page
->where('extra_nav_links.0.title', 'Somewhere else')
->where('extra_nav_links.0.external', true),
);
});
// The sidebar is the administration area. A client's portal shows their
// own files and nothing about the installation, so these must not reach
// them however careless a listener is.
test('a client gets no contributed links, even from a listener that adds unconditionally', function () {
Event::listen(ResolvingNavigationLinks::class, function (ResolvingNavigationLinks $event): void {
$event->add('Staff only really', 'https://example.test/');
});
$client = User::factory()->client()->create();
$this->actingAs($client)->get('/my-files')->assertInertia(
fn (AssertableInertia $page) => $page->where('extra_nav_links', []),
);
});
// One band. A dashboard that can accumulate banners accumulates them, and
// the second is what teaches people to skip the first.
test('the first listener to set a callout keeps it', function () {
Event::listen(ResolvingAnnouncement::class, function (ResolvingAnnouncement $event): void {
$event->show('First', 'Set first.');
});
Event::listen(ResolvingAnnouncement::class, function (ResolvingAnnouncement $event): void {
$event->show('Second', 'Should not win.');
});
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page->where('announcement.title', 'First'),
);
});
test('an unknown tone falls back rather than rendering unstyled', function () {
Event::listen(ResolvingAnnouncement::class, function (ResolvingAnnouncement $event): void {
$event->show('T', 'B', tone: 'chartreuse');
});
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page->where('announcement.tone', 'info'),
);
});
// The header icon and the dashboard band read one shared prop, so a
// message reaches somebody who never opens the dashboard. Two props would
// have drifted the first time anybody edited one.
test('the same message is available away from the dashboard', function () {
Event::listen(ResolvingAnnouncement::class, function (ResolvingAnnouncement $event): void {
$event->show('Everywhere', 'Not only on the dashboard.');
});
$this->actingAs($this->admin)->get('/system/settings/general')->assertInertia(
fn (AssertableInertia $page) => $page->where('announcement.title', 'Everywhere'),
);
});
// A client's header carries the bell too. Nothing addressed to staff may
// appear there, however careless the listener.
test('a client is never shown one, even from a listener that sets it unconditionally', function () {
Event::listen(ResolvingAnnouncement::class, function (ResolvingAnnouncement $event): void {
if (! $event->isStaff) {
return;
}
$event->show('Staff only', 'Not for clients.');
});
$client = User::factory()->client()->create();
$this->actingAs($client)->get('/my-files')->assertInertia(
fn (AssertableInertia $page) => $page->where('announcement', null),
);
});
@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Platform\Capabilities\Edition;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
$this->admin = User::factory()->create();
// Settings survive the per-test rollback, so nothing here may assume
// a default — see the note in CaptchaSettingsTest.
$settings = app(Settings::class);
$settings->set(Setting::CheckForUpdates, true);
$settings->set(Setting::FetchNews, true);
config()->set('projectsend.edition', Edition::Community);
});
/*
|--------------------------------------------------------------------------
| Two daily calls out of the container, and who may stop them
|--------------------------------------------------------------------------
|
| An operator could stop neither. The news feed had no switch of any kind,
| and the update check had one whose default is on — so a managed fleet
| believed it had disabled updates through an environment variable that
| nothing in this application reads.
|
| They are not the same case, and are not fixed the same way. Which
| mechanism each gets is the point of these tests.
*/
test('the news feed can be switched off, and says so rather than failing', function () {
Http::fake();
app(Settings::class)->set(Setting::FetchNews, false);
$this->artisan('projectsend:fetch-news')
->expectsOutputToContain('switched off')
->assertSuccessful();
// Not merely "no items stored" — the request never left.
Http::assertNothingSent();
});
test('the news feed is on by default, so nothing changes for an existing install', function () {
Http::fake(['*' => Http::response([])]);
$this->artisan('projectsend:fetch-news')->assertSuccessful();
Http::assertSentCount(1);
});
// The news itself is both editions — a Cloud client with view_news sees
// that card. What is Community-only is the *choice*: announcements about
// the product are what a hosted customer should be told, and one
// administrator switching them off for everybody on that instance is not
// a decision the platform hands over.
//
// The exact opposite of the update check below, which does not run on a
// managed instance at all. The two look alike and point in different
// directions, so both directions are pinned.
test('a managed instance fetches the news whatever its setting says', function () {
Http::fake(['*' => Http::response([])]);
config()->set('projectsend.edition', Edition::Cloud);
// Off — including a row left behind by an instance that used to be
// self-hosted, which is the case that would otherwise go silent.
app(Settings::class)->set(Setting::FetchNews, false);
$this->artisan('projectsend:fetch-news')->assertSuccessful();
Http::assertSentCount(1);
});
test('a managed instance is not offered the switch, and cannot be sent it', function () {
config()->set('projectsend.edition', Edition::Cloud);
app(Settings::class)->set(Setting::FetchNews, true);
$this->actingAs($this->admin)->get('/system/settings/general')->assertInertia(
fn (Inertia\Testing\AssertableInertia $page) => $page
->where('can_configure_news', false)
->where('fetch_news', null),
);
// A hand-crafted PATCH must not do what the absent checkbox could not.
$this->actingAs($this->admin)
->patch('/system/settings/general', generalPayload(['fetch_news' => false]))
->assertRedirect();
expect(app(Settings::class)->get(Setting::FetchNews))->toBeTrue();
});
/*
|--------------------------------------------------------------------------
| The update check is the other kind
|--------------------------------------------------------------------------
|
| On a managed installation the result is unreachable rather than
| unwanted: the dashboard's System card and the update UI are both gated
| on Capability::SystemUpdates, which is Community-only, and the image is
| chosen by whoever provisioned the instance. That is a fact about the
| edition, not a preference — so it is a capability, not a Setting.
*/
test('the update check does not run where its answer could never be seen', function () {
Http::fake();
config()->set('projectsend.edition', Edition::Cloud);
// On, and it still must not call out: the capability decides first.
app(Settings::class)->set(Setting::CheckForUpdates, true);
$this->artisan('projectsend:check-for-updates')
->expectsOutputToContain('do not apply')
->assertSuccessful();
Http::assertNothingSent();
});
test('a self-hosted install keeps its own switch, both ways', function () {
Http::fake(['*' => Http::response([])]);
app(Settings::class)->set(Setting::CheckForUpdates, false);
$this->artisan('projectsend:check-for-updates')
->expectsOutputToContain('disabled')
->assertSuccessful();
Http::assertNothingSent();
app(Settings::class)->set(Setting::CheckForUpdates, true);
$this->artisan('projectsend:check-for-updates')->assertSuccessful();
Http::assertSentCount(1);
});
/*
|--------------------------------------------------------------------------
| Reachable without a shell
|--------------------------------------------------------------------------
|
| A setting an operator cannot find is not a switch, it is a row. The
| update toggle beside it is hidden where the capability is absent; this
| one must not be, because the card it controls is shown in both editions.
*/
test('a self-hosted installation is offered the switch', function () {
$this->actingAs($this->admin)->get('/system/settings/general')->assertInertia(
fn (Inertia\Testing\AssertableInertia $page) => $page
->where('can_configure_news', true)
->where('fetch_news', true),
);
});
test('saving the settings page can turn the feed off and on', function () {
Http::fake();
$this->actingAs($this->admin)
->patch('/system/settings/general', generalPayload(['fetch_news' => false]))
->assertRedirect();
expect(app(Settings::class)->get(Setting::FetchNews))->toBeFalse();
$this->artisan('projectsend:fetch-news')->assertSuccessful();
Http::assertNothingSent();
$this->actingAs($this->admin)
->patch('/system/settings/general', generalPayload(['fetch_news' => true]))
->assertRedirect();
expect(app(Settings::class)->get(Setting::FetchNews))->toBeTrue();
});
/** The general form posts every field it owns; only the interesting one varies. */
function generalPayload(array $overrides = []): array
{
return array_merge([
'site_name' => 'ProjectSend',
'timezone' => 'UTC',
], $overrides);
}