Files
projectsend/app/Modules/Platform/Http/Controllers/PrivacySettingsController.php
T
ignacionelson bccf3d1f29 Stop serving a self-deleted account's files, and let them go at once
Reported from the shared free instance. A client deleted their own account
on 2026-09-18. Their five files kept serving through share links with no
expiry for the whole 30-day grace period. Somebody who asked to leave
stayed published.

Rule 1: once an account is soft-deleted, its uploads are served to nobody
but staff. That covers the client scope (assignment, group, shared
folder), share links, the public listing, public comments and zips.
Staff keep them, because the grace period exists to undo a mistake.
Nothing is deleted and share links are kept, so a restored account is
served again. A withdrawn share link answers like a token that never
existed. In practice this only meets self-deleted accounts: an
administrator deleting an account that owns anything must already choose
to delete or reassign it.

Rule 2, new in Privacy settings: when someone deletes their own account,
their files are deleted "when the grace period ends" (the default, and
today's behaviour) or "right away". "Right away" uses
DeletedAccountContent's cascade: their own uploads, and their folders only
if nothing else is left inside. It runs in the same transaction as the
account delete. A platform can force "right away" through the new
ResolvingSelfDeletion hook. The screen then shows the choice as set by
the hosting plan instead of offering a switch.

A second setting decides whose deletion both rules apply to: any account
(the default) or clients only. A staff member's uploads are often the
organization's work for its clients.

The delete-account screen now says what happens to the files before the
person confirms. New strings are in all sixteen locales.
2026-09-24 16:58:48 -03:00

101 lines
5.1 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Platform\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Identity\AccountContentDeletion;
use App\Modules\Identity\Erasure\SelfDeletion;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
/**
* System-wide privacy settings (staff-only): download IP logging
* granularity, the account-erasure retention window, what deleting your
* own account does to your files (see SelfDeletion), how long API request
* telemetry is kept, and whether to discourage search engines from
* indexing this installation.
*/
class PrivacySettingsController extends Controller
{
public function __construct(
private readonly Settings $settings,
private readonly ActivityLogger $activity,
private readonly AccountContentDeletion $accountDeletion,
private readonly SelfDeletion $selfDeletion,
) {}
public function edit(Request $request): Response
{
return Inertia::render('system/settings/privacy', [
'download_ip_logging' => $this->settings->get(Setting::DownloadIpLogging),
'account_erasure_grace_days' => $this->settings->get(Setting::AccountErasureGraceDays),
'account_erasure_content_action' => $this->settings->get(Setting::AccountErasureContentAction),
'account_erasure_reassign_to' => $this->settings->get(Setting::AccountErasureReassignTo),
// Installation-wide on purpose: this is the default every
// erasure will use, stored once for everybody, and the page is
// already behind edit_settings.
'reassign_candidates' => $this->accountDeletion->candidates(null),
// The effective answer, not the stored one: where a platform
// has made the choice, the screen shows what will happen and
// says who decided, instead of a switch that does nothing.
'account_self_delete_files' => $this->selfDeletion->deletesFilesImmediately() ? 'immediately' : 'after_grace_period',
'account_self_delete_files_managed' => $this->selfDeletion->isManaged(),
'account_self_delete_scope' => $this->settings->get(Setting::AccountSelfDeleteScope),
'api_request_log_retention_days' => $this->settings->get(Setting::ApiRequestLogRetentionDays),
'discourage_search_indexing' => $this->settings->get(Setting::DiscourageSearchIndexing),
]);
}
public function update(Request $request): RedirectResponse
{
$validated = $request->validate([
'download_ip_logging' => ['required', Rule::in(['all', 'anonymous_only', 'none'])],
'account_erasure_grace_days' => ['required', 'integer', 'min:0'],
'account_erasure_content_action' => ['required', Rule::in(['cascade_delete', 'reassign'])],
'account_erasure_reassign_to' => [
'nullable',
'integer',
'required_if:account_erasure_content_action,reassign',
Rule::exists('users', 'id')->where('active', true),
],
'account_self_delete_files' => ['required', Rule::in(['after_grace_period', 'immediately'])],
'account_self_delete_scope' => ['required', Rule::in(['any', 'clients'])],
'api_request_log_retention_days' => ['required', 'integer', 'min:0', 'max:3650'],
'discourage_search_indexing' => ['required', 'boolean'],
]);
$this->settings->set(Setting::DownloadIpLogging, $validated['download_ip_logging']);
$this->settings->set(Setting::AccountErasureGraceDays, $validated['account_erasure_grace_days']);
$this->settings->set(Setting::AccountErasureContentAction, $validated['account_erasure_content_action']);
// Only meaningful for 'reassign'; store 0 otherwise so a later switch
// back to 'cascade_delete' doesn't leave a stale target lying around.
$this->settings->set(
Setting::AccountErasureReassignTo,
$validated['account_erasure_content_action'] === 'reassign' ? (int) $validated['account_erasure_reassign_to'] : 0,
);
// Not written while a platform decides it. The screen shows that
// choice with the control disabled, so what comes back is only the
// platform's answer echoed; storing it would record a decision
// this installation never made.
if (! $this->selfDeletion->isManaged()) {
$this->settings->set(Setting::AccountSelfDeleteFiles, $validated['account_self_delete_files']);
}
$this->settings->set(Setting::AccountSelfDeleteScope, $validated['account_self_delete_scope']);
$this->settings->set(Setting::ApiRequestLogRetentionDays, $validated['api_request_log_retention_days']);
$this->settings->set(Setting::DiscourageSearchIndexing, $validated['discourage_search_indexing']);
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'privacy']);
return back();
}
}