1 Commits

Author SHA1 Message Date
Eliana Bracciaforte 62245befc7 Give the browser icons the favicon treatment the mark deserved
The favicon.svg was a raw export of the artwork's own bounds — 234
wide by 252 tall — so every square slot a browser put it in letterboxed
it slightly. The portal's icons were padded to a square in
app-customer-portal (4e0f70d there); this is the same treatment for the
project's own edition, in the project gradient. One shape for the
project, one gradient per edition: the colour is what tells an admin's
ProjectSend tab and the Cloud portal apart.

The rasters — the .ico's 16/32/48 and the 180px touch icon — are
regenerated from that square source, so the touch icon carries the same
margin the portal's has instead of running edge to edge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 13:41:12 -03:00
43 changed files with 139 additions and 1266 deletions
+16 -41
View File
@@ -13,52 +13,27 @@ 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 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.
- 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.
**Fixed**
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.
- 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.
**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.
### 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
Reported by [@Noorkhalel](https://github.com/Noorkhalel) (GHSA-whmp-p9hv-r7j7).
## 2.3.0 — 1 September 2026
@@ -24,10 +24,7 @@ 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
@@ -87,19 +84,6 @@ 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
@@ -317,43 +301,4 @@ 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,7 +12,6 @@ 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,65 +31,32 @@ 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), self::PREVIEW_LINK_SECONDS);
return $this->make($file, ContentDisposition::inline($file->original_name));
}
/** Handed over — a download. */
public function attachment(File $file): Response|RedirectResponse
{
return $this->make($file, ContentDisposition::attachment($file->original_name), self::DOWNLOAD_LINK_SECONDS);
return $this->make($file, ContentDisposition::attachment($file->original_name));
}
private function make(File $file, string $disposition, int $linkSeconds): Response|RedirectResponse
private function make(File $file, string $disposition): Response|RedirectResponse
{
if ($file->disk !== 'files') {
$url = Storage::disk($file->disk)->temporaryUrl(
$file->path,
now()->addSeconds($linkSeconds),
now()->addHour(),
['ResponseContentDisposition' => $disposition],
);
+75 -141
View File
@@ -27,12 +27,6 @@ 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
@@ -146,21 +140,9 @@ class LocalPartStore
}
/**
* 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.
* 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).
*
* @return array{path: string, disk: string, size: int, checksum: string}
*/
@@ -176,93 +158,6 @@ 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) {
@@ -272,46 +167,85 @@ class LocalPartStore
$hash = hash_init('sha256');
$size = 0;
try {
foreach ($parts as $part) {
$in = fopen($this->partPath($session, $part['PartNumber']), 'rb');
foreach ($parts as $part) {
$partPath = $this->partPath($session, $part['PartNumber']);
$in = fopen($partPath, 'rb');
if ($in === false) {
throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
}
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);
}
if ($in === false) {
fclose($out);
throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
}
} catch (Throwable $failure) {
fclose($out);
throw $failure;
while (! feof($in)) {
$buffer = fread($in, 1024 * 1024);
if ($buffer === false) {
break;
}
fwrite($out, $buffer);
hash_update($hash, $buffer);
$size += strlen($buffer);
}
fclose($in);
unlink($partPath);
}
// 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);
fclose($out);
$readStream = fopen($assembledPath, 'rb');
if ($readStream === false) {
throw new RuntimeException('Could not reopen assembled file.');
}
return [$size, hash_final($hash)];
$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),
];
}
public function abort(UploadSession $session): void
@@ -1,64 +0,0 @@
<?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,24 +32,6 @@ 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
@@ -168,7 +150,6 @@ enum Capability: string
self::StorageConfigure,
self::EmailTransportConfigure,
self::SystemUpdates,
self::NewsConfigure,
self::SchedulerMonitoring,
self::CustomAssets => [Edition::Community],
@@ -45,10 +45,6 @@ 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),
@@ -66,15 +62,6 @@ 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'),
]);
@@ -136,10 +123,6 @@ 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'],
@@ -150,10 +133,6 @@ 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
@@ -177,13 +156,6 @@ 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();
@@ -1,56 +0,0 @@
<?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,8 +4,6 @@ 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;
@@ -14,13 +12,9 @@ use Illuminate\Support\Facades\Http;
use Stevebauman\Purify\Facades\Purify;
/**
* 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.
* 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.
*
* The feed returns raw HTML in `content` (links, paragraphs) — sanitized
* here, once, before it's ever cached or sent to the frontend, so the
@@ -40,36 +34,12 @@ 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,17 +249,6 @@ 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).
@@ -378,7 +367,6 @@ enum Setting: string
self::ClientsCanPreviewFiles,
self::PublicListingPreviewEnabled,
self::CheckForUpdates,
self::FetchNews,
self::ExpiredFilesAutoDeleteEnabled,
self::PublicCommentsEnabled,
self::CommentsGuestModeration,
@@ -445,7 +433,6 @@ 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,8 +4,6 @@ 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;
@@ -34,37 +32,12 @@ 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.4.0',
'version' => '2.3.0',
/*
|--------------------------------------------------------------------------
+1 -5
View File
@@ -2013,9 +2013,5 @@
"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.",
"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"
"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."
}
+1 -5
View File
@@ -2013,9 +2013,5 @@
"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í.",
"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ší"
"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í."
}
+1 -5
View File
@@ -2013,9 +2013,5 @@
"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.",
"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"
"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."
}
+1 -5
View File
@@ -2013,9 +2013,5 @@
"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.",
"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"
"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."
}
+1 -5
View File
@@ -2013,9 +2013,5 @@
"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.",
"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"
"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."
}
+1 -5
View File
@@ -2013,9 +2013,5 @@
"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.",
"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"
"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."
}
+1 -5
View File
@@ -2013,9 +2013,5 @@
"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.",
"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"
"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."
}
+1 -5
View File
@@ -2013,9 +2013,5 @@
"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.": "このサイトにはまだ公開ページが設定されていないため、管理者が設定するまで何も表示されません。",
"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": "その他"
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "このサイトにはまだ公開ページが設定されていないため、管理者が設定するまで何も表示されません。"
}
+1 -5
View File
@@ -2013,9 +2013,5 @@
"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.",
"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"
"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."
}
+1 -5
View File
@@ -2013,9 +2013,5 @@
"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.",
"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"
"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."
}
+1 -5
View File
@@ -2013,9 +2013,5 @@
"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.",
"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"
"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."
}
+1 -5
View File
@@ -2013,9 +2013,5 @@
"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.": "На этом сайте ещё не настроена публичная страница, поэтому ничего не будет видно, пока администратор её не настроит.",
"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": "Ещё"
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "На этом сайте ещё не настроена публичная страница, поэтому ничего не будет видно, пока администратор её не настроит."
}
+1 -5
View File
@@ -2013,9 +2013,5 @@
"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.",
"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"
"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."
}
+1 -5
View File
@@ -2013,9 +2013,5 @@
"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.",
"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"
"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."
}
+1 -5
View File
@@ -2013,9 +2013,5 @@
"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.",
"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"
"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."
}
+1 -5
View File
@@ -2013,9 +2013,5 @@
"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.": "本站尚未设置公开页面,因此在管理员设置之前不会显示任何内容。",
"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": "更多"
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "本站尚未设置公开页面,因此在管理员设置之前不会显示任何内容。"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+16 -5
View File
@@ -1,11 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.26482 252.25172">
<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.
-->
<defs>
<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 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>
</defs>
<g transform="translate(-2.5,-2.1253469)">
<g transform="translate(-2.5,-2.13)">
<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.1 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

-114
View File
@@ -1,114 +0,0 @@
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,4 +1,3 @@
import { AnnouncementIcon } from '@/components/announcement';
import { AppearanceSwitcher } from '@/components/appearance-switcher';
import { Breadcrumbs } from '@/components/breadcrumbs';
import { IconLocaleSwitcher } from '@/components/locale-switcher';
@@ -16,7 +15,6 @@ export function AppSidebarHeader({ breadcrumbs = [] }: { breadcrumbs?: Breadcrum
</div>
<div className="ml-auto flex items-center gap-1">
<UpdateAvailableIcon />
<AnnouncementIcon />
<NotificationBell />
<AppearanceSwitcher />
<IconLocaleSwitcher />
+1 -18
View File
@@ -32,7 +32,7 @@ import AppLogo from './app-logo';
export function AppSidebar() {
const { t } = useTranslation();
const { auth, capabilities, extra_nav_links, pending, version } = usePage<SharedData>().props;
const { auth, capabilities, pending, version } = usePage<SharedData>().props;
// Modules (Files, Clients, Groups…) add their own groups here as
// they land, mirroring v1's grouped admin menu.
@@ -262,23 +262,6 @@ 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>
+6 -26
View File
@@ -12,7 +12,7 @@ import {
} from '@/components/ui/sidebar';
import { type NavGroup } from '@/types';
import { Link, usePage } from '@inertiajs/react';
import { ChevronRight, ExternalLink } from 'lucide-react';
import { ChevronRight } from 'lucide-react';
export function NavMain({ groups = [] }: { groups: NavGroup[] }) {
const page = usePage();
@@ -58,31 +58,11 @@ export function NavMain({ groups = [] }: { groups: NavGroup[] }) {
</Collapsible>
) : (
<SidebarMenuItem key={item.title}>
<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 asChild isActive={isActive(item.url)} tooltip={item.title}>
<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">
+1 -8
View File
@@ -28,7 +28,6 @@ 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';
@@ -111,7 +110,7 @@ export default function Dashboard({
dashboard_columns,
}: DashboardProps) {
const { t } = useTranslation();
const { announcement, update_notice } = usePage<SharedData>().props;
const { update_notice } = usePage<SharedData>().props;
const [releaseDialogOpen, setReleaseDialogOpen] = useState(false);
const [widgetsDialogOpen, setWidgetsDialogOpen] = useState(false);
const [layout, setLayout] = useState<WidgetLayout>(widget_layout);
@@ -337,12 +336,6 @@ 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,8 +23,6 @@ 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. */
@@ -38,8 +36,6 @@ export default function SystemSettings({
viewer_timezone,
can_manage_updates,
check_for_updates,
fetch_news,
can_configure_news,
last_checked_at,
check_result,
}: SystemSettingsProps) {
@@ -79,7 +75,6 @@ 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) => {
@@ -137,30 +132,6 @@ 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,4 +1,3 @@
import { type Announcement } from '@/components/announcement';
import { type InstallKind } from '@/components/update-instructions';
import { LucideIcon } from 'lucide-react';
@@ -24,12 +23,6 @@ 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';
@@ -39,7 +32,6 @@ export type Capability =
| 'storage.configure'
| 'email.transport.configure'
| 'system.updates'
| 'news.configure'
| 'scheduler.monitoring'
| 'custom_assets.manage'
| 'branding.customize'
@@ -47,19 +39,6 @@ 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;
@@ -111,14 +90,6 @@ 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,61 +329,6 @@ 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,9 +3,7 @@
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 () {
@@ -63,70 +61,3 @@ 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());
});
@@ -1,130 +0,0 @@
<?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),
);
});
@@ -1,185 +0,0 @@
<?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);
}