Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions config/routes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ about:
defaults:
template: 'about/about.html.twig'

about_version_immutability:
path: '/about/version-immutability'
controller: 'Symfony\Bundle\FrameworkBundle\Controller\TemplateController'
defaults:
template: 'about/version_immutability.html.twig'

about_composer:
path: '/about-composer'
controller: 'Symfony\Bundle\FrameworkBundle\Controller\RedirectController::urlRedirectAction'
Expand Down
49 changes: 45 additions & 4 deletions css/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -1314,11 +1314,14 @@ input:focus:invalid:focus, textarea:focus:invalid:focus, select:focus:invalid:fo
margin-right: 4px;
}
.package .package-aside a.advisory-alert {
margin-left: -20px;
margin-left: -40px;
}
.package .package-aside a.malware-alert {
margin-left: -40px;
}
.package .package-aside a.retag-blocked-alert {
margin-left: -40px;
}
.package .package-aside a.action-alert:hover, .package .package-aside a.action-alert:active {
text-decoration: none;
}
Expand Down Expand Up @@ -1385,17 +1388,54 @@ input:focus:invalid:focus, textarea:focus:invalid:focus, select:focus:invalid:fo
display: block;
height: 26px;
}
.package .delete-version {
.package .delete-version, .package .hide-version {
display: inline-block;
}
.package .delete-version .submit {
.package .delete-version .submit, .package .hide-version .submit {
position: absolute;
right: 5px;
top: 7px;
cursor: pointer;
}
.package .delete-version .submit { right: 5px; }
.package .hide-version .submit { right: 25px; color: #999; }
.package .delete-version .submit:hover, .package .package-aside a.action-alert:hover i {
color: #cd3729;
}
.package .hide-version .submit:hover {
color: #b87800;
}

.package .versions .version.version-soft-deleted .version-number {
color: #999;
text-decoration: line-through;
text-decoration-color: rgba(153, 153, 153, 0.5);
}
.package .package-aside .deletion-alert {
position: absolute;
right: 28px;
top: 2px;
cursor: help;
}
.package .package-aside .deletion-alert i {
color: #999;
font-size: 12px;
}
.package .package-aside a.retag-blocked-alert i {
color: #b87800;
}
.package .recover-version {
display: inline-block;
}
.package .recover-version .submit {
position: absolute;
right: 5px;
top: 6px;
cursor: pointer;
color: #999;
}
.package .recover-version .submit:hover {
color: #3c763d;
}

@media (min-width: 992px) {
.search-facets .ais-Menu-count, .search-facets .ais-RefinementList-count {
Expand Down Expand Up @@ -1427,6 +1467,7 @@ input:focus:invalid:focus, textarea:focus:invalid:focus, select:focus:invalid:fo
.package .delete-version .submit {
right: 15px;
}
.package .hide-version .submit { right: 35px; }
}
@media (max-width: 767px) {
.package-aside .facts {
Expand Down
107 changes: 101 additions & 6 deletions js/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -191,23 +191,118 @@ const init = function ($) {
const details = e.target.dataset.details;
notifier.log(message, {}, details);
});
$('.package .delete-version .submit').on('click', function (e) {
$('.package .delete-version .submit, .package .recover-version .submit, .package .hide-version .submit').on('click', function (e) {
e.preventDefault();
e.stopImmediatePropagation();
$(e.target).closest('form').submit();
});

function getVersionLabel(form) {
return $(form).closest('.version').find('.version-number').text().trim();
}

function applyVersionDeleteResponse(form, data, deletedToast) {
var row = $(form).closest('.version');
if (data && data.softDeleted) {
notifier.log('Version soft-deleted. Reload the page to access the recovery action.', {timeout: 4000});
row.addClass('version-soft-deleted');
if (!row.find('.deletion-alert').length) {
var icon = data.deletionIcon || 'glyphicon-trash';
var alert = $('<span class="action-alert deletion-alert"><i class="glyphicon"></i></span>');
alert.find('i').addClass(icon);
alert.attr('title', data.deletionTitle || 'Deleted');
alert.insertBefore(row.find('form').first());
}
row.find('.delete-version, .hide-version').remove();
} else {
notifier.log(deletedToast, {timeout: 3000});
row.remove();
}
}

// Submit a version-action form via ajax, guarding against duplicate submits.
// `overrides` may carry {url, type, data} to override the form's defaults (used by the
// admin-reason fallthrough in .delete-version which retargets to admin_delete_version).
// Returns the jqXHR, or null if the request-sent guard tripped.
function dispatchVersionAction(form, onSuccess, overrides) {
if ($(form).is('.request-sent')) {
return null;
}
overrides = overrides || {};
$(form).addClass('request-sent');
return $.ajax({
url: overrides.url || $(form).attr('action'),
type: overrides.type || $(form).attr('method'),
cache: false,
dataType: 'json',
data: overrides.data || $(form).serializeArray(),
success: onSuccess,
complete: function () { $(form).removeClass('request-sent'); }
});
}

$('.package .recover-version').on('submit', function (e) {
e.preventDefault();
e.stopImmediatePropagation();
var form = this;
dispatchVersionAction(form, function () {
notifier.log('Version recovered. Reload the page to see the active version.', {timeout: 3000});
var row = $(form).closest('.version');
row.removeClass('version-soft-deleted');
row.find('.deletion-alert').remove();
$(form).remove();
});
});

$('.package .delete-version').on('submit', function (e) {
e.preventDefault();
e.stopImmediatePropagation();
var form = this;
if (window.confirm('Are you sure you want to delete ' + $(form).prev().text() + '?')) {
dispatchAjaxForm(this, function () {
notifier.log('Version successfully deleted', {timeout: 3000});
$(form).closest('.version').remove();
}, 'request-sent');
var label = getVersionLabel(form);
var overrides = {};

if ($(form).data('admin')) {
var reason = window.prompt('Reason text for admin removal of ' + label + ' (leave blank to record without a reason, cancel to abort):', '');
if (reason === null) {
return;
}
reason = reason.trim();
if (reason !== '') {
overrides.url = $(form).data('admin-url');
overrides.type = 'POST';
overrides.data = [
{name: '_token', value: $(form).find('input[name="_token"]').val()},
{name: 'reason', value: reason}
];
}
} else if (!window.confirm('Are you sure you want to delete ' + label + '?')) {
return;
}

dispatchVersionAction(form, function (data) {
applyVersionDeleteResponse(form, data, 'Version successfully deleted');
}, overrides);
});

$('.package .hide-version').on('submit', function (e) {
e.preventDefault();
e.stopImmediatePropagation();
var form = this;
var label = getVersionLabel(form);
var reason = window.prompt('Reason text for hiding ' + label + ' from public (leave blank to record without a reason, cancel to abort):', '');
if (reason === null) {
return;
}
reason = reason.trim();
var data = $(form).serializeArray();
if (reason !== '') {
data.push({name: 'reason', value: reason});
}
dispatchVersionAction(form, function (resp) {
applyVersionDeleteResponse(form, resp, 'Version hidden');
}, {data: data});
});

$('.package').on('click', '.requireme input', function () {
this.select();
});
Expand Down
20 changes: 20 additions & 0 deletions migrations/2026_05_version_immutability.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- Version immutability + soft-delete reasons
--
-- Adds:
-- deletionReason enum-string column (auto_missing | maintainer | admin | hidden)
-- deletionReasonText optional human-readable reason for admin takedowns
-- lastBlockedReference last attempted source/dist ref that was refused on a stable version
-- index over (softDeletedAt, deletionReason) to keep the purge sweep fast
--
-- The UPDATE seeds existing softDeletedAt rows with the auto_missing reason so the
-- invariant "softDeletedAt IS NOT NULL ⇔ deletionReason IS NOT NULL" holds everywhere.

ALTER TABLE package_version
ADD COLUMN deletionReason VARCHAR(32) NULL AFTER softDeletedAt,
ADD COLUMN deletionReasonText TEXT NULL AFTER deletionReason,
ADD COLUMN lastBlockedReference VARCHAR(255) NULL AFTER deletionReasonText,
ADD INDEX softdel_reason_idx (softDeletedAt, deletionReason);

UPDATE package_version
SET deletionReason = 'auto_missing'
WHERE softDeletedAt IS NOT NULL;
2 changes: 1 addition & 1 deletion phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ parameters:
path: src/Package/Updater.php

-
message: '#^Parameter \#1 \$job of method App\\Service\\UpdaterWorker\:\:process\(\) expects App\\Entity\\Job\<array\{id\: int, update_equal_refs\: bool, delete_before\: bool, force_dump\: bool, source\: string\}\>\|App\\Entity\\Job\<array\{id\: int, old_scope\: string, new_scope\: string\}\>\|App\\Entity\\Job\<array\{source\: string\}\>, App\\Entity\\Job\<array\<string, bool\|int\|string\>\> given\.$#'
message: '#^Parameter \#1 \$job of method App\\Service\\UpdaterWorker\:\:process\(\) expects App\\Entity\\Job\<array\{id\: int, update_source_dist_url\: bool, delete_before\: bool, force_dump\: bool, source\: string\}\>\|App\\Entity\\Job\<array\{id\: int, old_scope\: string, new_scope\: string\}\>\|App\\Entity\\Job\<array\{source\: string\}\>, App\\Entity\\Job\<array\<string, bool\|int\|string\>\> given\.$#'
identifier: argument.type
count: 1
path: src/Service/QueueWorker.php
4 changes: 2 additions & 2 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,14 @@ parameters:
VersionData: 'array<int, array{require: VersionDataLinks, devRequire: VersionDataLinks, suggest: VersionDataLinks, conflict: VersionDataLinks, provide: VersionDataLinks, replace: VersionDataLinks, tags: list<string>}>'
VersionDataLinks: 'array<array{version_id: int, name: string, version: string}>'

ExistingVersionsForUpdate: 'array<string, array{id: int, version: string, normalizedVersion: string, source: PackageSource, softDeletedAt: string|null, defaultBranch: int}>'
ExistingVersionsForUpdate: 'array<string, array{id: int, version: string, normalizedVersion: string, development: int, source: PackageSource, dist: PackageDist, softDeletedAt: string|null, deletionReason: string|null, lastBlockedReference: string|null, defaultBranch: int}>'

PackageAutoloadRules: 'array{psr-0?: array<string, string|string[]>, psr-4?: array<string, string|string[]>, classmap?: list<string>, files?: list<string>, exclude-from-classmap?: list<string>}'
PackageDist: 'array{url: string|null, type: string|null, reference: string|null, shasum: string|null}|null'
PackageSource: 'array{url: string|null, type: string|null, reference: string|null}|null'

AnyJob: 'array<string, bool|int|string>'
PackageUpdateJob: 'array{id: int, update_equal_refs: bool, delete_before: bool, force_dump: bool, source: string}'
PackageUpdateJob: 'array{id: int, update_source_dist_url: bool, delete_before: bool, force_dump: bool, source: string}'
GitHubUserMigrateJob: 'array{id: int, old_scope: string, new_scope: string}'
SecurityAdvisoryJob: 'array{source: string}'
FilterListJob: 'array{list: string, source: string}'
Expand Down
6 changes: 5 additions & 1 deletion src/Audit/AuditRecordType.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ enum AuditRecordType: string
// version
case VersionCreated = 'version_created';
case VersionReferenceChanged = 'version_reference_changed';
case VersionReferenceChangeBlocked = 'version_reference_change_blocked';
case VersionDeleted = 'version_deleted';
case VersionSoftDeleted = 'version_soft_deleted';
case VersionRecovered = 'version_recovered';

// user management
case UserCreated = 'user_created';
Expand All @@ -57,7 +60,8 @@ public function category(): string
self::MaintainerAdded, self::MaintainerRemoved, self::PackageTransferred => 'ownership',
self::PackageCreated, self::PackageDeleted, self::CanonicalUrlChanged,
self::PackageAbandoned, self::PackageUnabandoned, self::PackageFrozen, self::PackageUnfrozen => 'package',
self::VersionCreated, self::VersionDeleted, self::VersionReferenceChanged => 'version',
self::VersionCreated, self::VersionDeleted, self::VersionReferenceChanged,
self::VersionReferenceChangeBlocked, self::VersionSoftDeleted, self::VersionRecovered => 'version',
self::UserCreated, self::UserVerified, self::UserDeleted,
self::PasswordResetRequested, self::PasswordReset, self::PasswordChanged,
self::EmailChanged, self::UsernameChanged, self::GitHubLinkedWithUser,
Expand Down
26 changes: 26 additions & 0 deletions src/Audit/Display/AuditLogDisplayFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,32 @@ public function buildSingle(AuditRecord $record): AuditLogDisplayInterface
$this->buildActor($record->attributes['actor'] ?? null),
$record->ip,
),
AuditRecordType::VersionReferenceChangeBlocked => new VersionReferenceChangeBlockedDisplay(
$record->datetime,
$record->attributes['name'],
$record->attributes['version'],
$record->attributes['ref_from'] ?? null,
$record->attributes['ref_to'],
$this->buildActor($record->attributes['actor'] ?? null),
$record->ip,
),
AuditRecordType::VersionSoftDeleted => new VersionSoftDeletedDisplay(
$record->datetime,
$record->attributes['name'],
$record->attributes['version'],
$record->attributes['reason'],
$record->attributes['reasonText'] ?? null,
$this->buildActor($record->attributes['actor'] ?? null),
$record->ip,
),
AuditRecordType::VersionRecovered => new VersionRecoveredDisplay(
$record->datetime,
$record->attributes['name'],
$record->attributes['version'],
$record->attributes['previousReason'],
$this->buildActor($record->attributes['actor'] ?? null),
$record->ip,
),
AuditRecordType::UserCreated => new UserCreatedDisplay(
$record->datetime,
$record->attributes['user']['username'],
Expand Down
39 changes: 39 additions & 0 deletions src/Audit/Display/VersionRecoveredDisplay.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php declare(strict_types=1);

/*
* This file is part of Packagist.
*
* (c) Jordi Boggiano <j.boggiano@seld.be>
* Nils Adermann <naderman@naderman.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace App\Audit\Display;

use App\Audit\AuditRecordType;

readonly class VersionRecoveredDisplay extends AbstractAuditLogDisplay
{
public function __construct(
\DateTimeImmutable $datetime,
public string $packageName,
public string $version,
public string $previousReason,
ActorDisplay $actor,
?string $ip,
) {
parent::__construct($datetime, $actor, $ip);
}

public function getType(): AuditRecordType
{
return AuditRecordType::VersionRecovered;
}

public function getTemplateName(): string
{
return 'audit_log/display/version_recovered.html.twig';
}
}
Loading