mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-16 16:45:07 +00:00
38187dcf1a
The expired page offers a "send me a new one" button that re-issues the invitation with no staff member involved. On its own that is reasonable -- the new link goes to the address on the invitation, never to whoever clicked, so holding a leaked URL gets nobody a working one, and it is the same shape as a password reset. What it spent was the operator's expiry window. A link could be renewed from a dead link, indefinitely, so a window set to 72 hours was only ever as short as the longest anybody bothered to wait. That matters in the case expiry is actually for: a link sitting somewhere it should not be -- a forwarded thread, a shared inbox, a mailbox that changed hands. So the chain gets a limit: three renewals, then a staff member has to send a new invitation. The count is carried forward on each renewal rather than stored per row, which is what makes it apply to the chain; a staff-sent invitation starts at zero, because sending one is somebody deciding to. A renewal beyond the limit answers in exactly the same words as a spent, unknown or revoked token, and sends nothing. Four situations, one sentence: telling them apart is how this door would become a way to learn which addresses an installation has invited. Renewals are now logged, which they were not -- sending and redeeming already were, and renewing was the one step that moved an invitation along with nobody behind it and left no trace. The limit bounds how many rows an anonymous door can write. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
353 lines
21 KiB
PHP
353 lines
21 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Audit;
|
|
|
|
/**
|
|
* Every action the activity log can record — v2's replacement for v1's
|
|
* ~45 numbered action types in ActionsLog. Modules add their own cases
|
|
* (file.uploaded, group.created, …) as they land; the string values are
|
|
* stable identifiers stored in the database.
|
|
*/
|
|
enum Action: string
|
|
{
|
|
// Platform / lifecycle (v1 action 0: "ProjectSend has been installed")
|
|
case SetupCompleted = 'setup.completed';
|
|
case SettingsUpdated = 'settings.updated';
|
|
case ApplicationUpdated = 'application.updated';
|
|
|
|
// Identity
|
|
case Login = 'auth.login';
|
|
case Logout = 'auth.logout';
|
|
case UserCreated = 'user.created';
|
|
case UserUpdated = 'user.updated';
|
|
case UserDeleted = 'user.deleted';
|
|
case UserActivated = 'user.activated';
|
|
case UserDeactivated = 'user.deactivated';
|
|
case AccountErased = 'account.erased';
|
|
case AccountContentCascadeDeleted = 'account_content.cascade_deleted';
|
|
case AccountContentReassigned = 'account_content.reassigned';
|
|
// Deliberately their own cases rather than reusing user.updated: v1
|
|
// wrote its LDAP events onto the codes for "approved/denied an account
|
|
// request", so every LDAP login rendered in the log as an approval.
|
|
case AccountConvertedToClient = 'account.converted_to_client';
|
|
case AccountConvertedToStaff = 'account.converted_to_staff';
|
|
case ClientSelfRegistered = 'client.self_registered';
|
|
case LdapClientProvisioned = 'ldap.client_provisioned';
|
|
case SocialClientProvisioned = 'social.client_provisioned';
|
|
case SocialAccountLinked = 'social.account_linked';
|
|
case SocialAccountUnlinked = 'social.account_unlinked';
|
|
case ClientApproved = 'client.approved';
|
|
case ClientDenied = 'client.denied';
|
|
case ClientInvited = 'client.invited';
|
|
case ClientInvitationRedeemed = 'client.invitation_redeemed';
|
|
case ClientInvitationRevoked = 'client.invitation_revoked';
|
|
case ClientInvitationResent = 'client.invitation_resent';
|
|
// Files
|
|
case FileUploaded = 'file.uploaded';
|
|
case FileUpdated = 'file.updated';
|
|
case FileDeleted = 'file.deleted';
|
|
case FileDownloaded = 'file.downloaded';
|
|
case FilePreviewed = 'file.previewed';
|
|
case FileAssigned = 'file.assigned';
|
|
case FileUnassigned = 'file.unassigned';
|
|
case FileVersionLinked = 'file.version_linked';
|
|
case FileVersionUnlinked = 'file.version_unlinked';
|
|
case ShareLinkCreated = 'share_link.created';
|
|
case ShareLinkRevoked = 'share_link.revoked';
|
|
case ShareLinkDownloaded = 'share_link.downloaded';
|
|
case PublicFileDownloaded = 'public_file.downloaded';
|
|
case PublicFilePreviewed = 'public_file.previewed';
|
|
case FolderCreated = 'folder.created';
|
|
case FolderRenamed = 'folder.renamed';
|
|
case FolderMoved = 'folder.moved';
|
|
case FolderDeleted = 'folder.deleted';
|
|
case FolderShared = 'folder.shared';
|
|
case FolderUnshared = 'folder.unshared';
|
|
case FileMadePublic = 'file.made_public';
|
|
case FileMadePrivate = 'file.made_private';
|
|
case FolderMadePublic = 'folder.made_public';
|
|
case FolderMadePrivate = 'folder.made_private';
|
|
case UploadAborted = 'upload.aborted';
|
|
case FileImported = 'file.imported';
|
|
case OrphanFileDeleted = 'orphan_file.deleted';
|
|
case OrphanFileAutoDeleted = 'orphan_file.auto_deleted';
|
|
case ExpiredFileDeleted = 'file.expired_deleted';
|
|
|
|
case GroupMembershipLeft = 'group.membership_left';
|
|
case GroupMembershipRequested = 'group.membership_requested';
|
|
case GroupMembershipApproved = 'group.membership_approved';
|
|
case GroupMembershipDenied = 'group.membership_denied';
|
|
case GroupCreated = 'group.created';
|
|
case GroupUpdated = 'group.updated';
|
|
case GroupDeleted = 'group.deleted';
|
|
case GroupMadePublic = 'group.made_public';
|
|
case GroupMadePrivate = 'group.made_private';
|
|
case GroupMemberAdded = 'group.member_added';
|
|
case GroupMemberRemoved = 'group.member_removed';
|
|
case RoleCreated = 'role.created';
|
|
case RoleUpdated = 'role.updated';
|
|
case RoleDeleted = 'role.deleted';
|
|
case CategoryCreated = 'category.created';
|
|
case CategoryRenamed = 'category.renamed';
|
|
case CategoryDeleted = 'category.deleted';
|
|
case ClientCustomFieldCreated = 'client_custom_field.created';
|
|
case ClientCustomFieldUpdated = 'client_custom_field.updated';
|
|
case ClientCustomFieldDeleted = 'client_custom_field.deleted';
|
|
case ProfileUpdated = 'profile.updated';
|
|
case PasswordUpdated = 'password.updated';
|
|
case TwoFactorEnabled = 'two_factor.enabled';
|
|
case TwoFactorDisabled = 'two_factor.disabled';
|
|
case TwoFactorRecoveryCodesRegenerated = 'two_factor.recovery_codes_regenerated';
|
|
// Distinct from TwoFactorDisabled: that is somebody turning off their
|
|
// own second factor, this is an administrator turning off somebody
|
|
// else's. Same end state, very different thing to find in an audit
|
|
// trail — one of them is the shape an account takeover would take.
|
|
case TwoFactorReset = 'two_factor.reset';
|
|
|
|
// API tokens. Minting one creates a long-lived credential that acts
|
|
// with the owner's permissions outside any browser session, so both
|
|
// ends of its life are audit events in their own right.
|
|
case ApiTokenCreated = 'api_token.created';
|
|
case ApiTokenUpdated = 'api_token.updated';
|
|
case ApiTokenRevoked = 'api_token.revoked';
|
|
|
|
// Custom assets (community-modules package — see
|
|
// App\Modules\Audit\Listeners\LogCustomAssetActivity, which
|
|
// translates that package's events into these cases; the package
|
|
// itself has no dependency on this enum or on ActivityLogger).
|
|
case CustomAssetCreated = 'custom_asset.created';
|
|
case CustomAssetUpdated = 'custom_asset.updated';
|
|
case CustomAssetDeleted = 'custom_asset.deleted';
|
|
case CustomAssetEnabled = 'custom_asset.enabled';
|
|
case CustomAssetDisabled = 'custom_asset.disabled';
|
|
|
|
// Comments. The body is deliberately never recorded in the log's
|
|
// context: a comment can be visible to one client only, and the
|
|
// activity log is read by staff whose file scope may not include
|
|
// that client's thread.
|
|
case CommentPosted = 'comment.posted';
|
|
// A visitor's comment has no account behind it, so it would otherwise
|
|
// log with a null actor and read as "System" — the audit trail saying
|
|
// the installation commented on its own file, with the one detail
|
|
// moderation cares about (who claimed to write it) thrown away. Same
|
|
// reason FileDownloaded, PublicFileDownloaded and ShareLinkDownloaded
|
|
// are three cases rather than one: same event, different origin.
|
|
case CommentPostedByVisitor = 'comment.posted_by_visitor';
|
|
case CommentEdited = 'comment.edited';
|
|
case CommentDeleted = 'comment.deleted';
|
|
case CommentApproved = 'comment.approved';
|
|
|
|
/**
|
|
* Full sentence for a log row, with :placeholders resolved from the
|
|
* entry's subject name (:subject) and context keys. English text is
|
|
* the translation key; translations keep the placeholders.
|
|
*/
|
|
public function template(): string
|
|
{
|
|
return match ($this) {
|
|
self::SetupCompleted => 'Installed ProjectSend',
|
|
self::ApplicationUpdated => 'Updated ProjectSend to :to, from :from',
|
|
self::SettingsUpdated => 'Updated the system settings (:section)',
|
|
self::Login => 'Logged in',
|
|
self::Logout => 'Logged out',
|
|
self::UserCreated => 'Created the account ":subject"',
|
|
self::UserUpdated => 'Updated the account ":subject"',
|
|
self::UserDeleted => 'Deleted the account ":name"',
|
|
self::AccountConvertedToClient => 'Converted the staff account ":subject" to a client',
|
|
self::AccountConvertedToStaff => 'Converted the client account ":subject" to staff (:role)',
|
|
self::LdapClientProvisioned => 'Created a client account from the directory on first sign-in',
|
|
self::SocialClientProvisioned => 'Created a client account from :provider on first sign-in',
|
|
self::SocialAccountLinked => 'Connected the :provider account of ":subject"',
|
|
self::SocialAccountUnlinked => 'Disconnected the :provider account of ":subject"',
|
|
self::UserActivated => 'Activated the account ":subject"',
|
|
self::UserDeactivated => 'Deactivated the account ":subject"',
|
|
self::AccountErased => 'Permanently erased a deleted account',
|
|
self::AccountContentCascadeDeleted => 'Deleted :files file(s) and :folders folder(s) belonging to the deleted account ":name"',
|
|
self::AccountContentReassigned => 'Reassigned :files file(s) and :folders folder(s) from the deleted account ":name" to :target',
|
|
self::ClientSelfRegistered => 'Registered a new client account',
|
|
self::ClientApproved => 'Approved the account request of ":subject"',
|
|
self::ClientDenied => 'Denied the account request of ":name"',
|
|
self::ClientInvited => 'Invited :email to register a client account',
|
|
self::ClientInvitationRedeemed => 'Registered a client account from an invitation',
|
|
self::ClientInvitationRevoked => 'Revoked the invitation sent to :email',
|
|
self::ClientInvitationResent => 'A new invitation link was requested for :email',
|
|
self::FileUploaded => 'Uploaded the file ":subject"',
|
|
self::FileUpdated => 'Updated the file ":subject"',
|
|
self::FileDeleted => 'Deleted the file ":name"',
|
|
self::FileDownloaded => 'Downloaded the file ":subject"',
|
|
self::FilePreviewed => 'Previewed the file ":subject"',
|
|
self::FileAssigned => 'Assigned the file ":subject" to :target',
|
|
self::FileUnassigned => 'Removed the file ":subject" from :target',
|
|
// :previous is a name snapshot in context, not a lookup — same
|
|
// reason FileDeleted carries :name: the entry has to still read
|
|
// correctly once the original is gone.
|
|
self::FileVersionLinked => 'Marked the file ":subject" as a revision of ":previous"',
|
|
self::FileVersionUnlinked => 'Removed the version link from the file ":subject"',
|
|
self::ShareLinkCreated => 'Created a public link for the file ":subject"',
|
|
self::ShareLinkRevoked => 'Revoked a public link for the file ":subject"',
|
|
self::ShareLinkDownloaded => 'Downloaded the file ":subject" via a public link',
|
|
self::PublicFileDownloaded => 'Downloaded the file ":subject" via the public group listing',
|
|
self::PublicFilePreviewed => 'Previewed the file ":subject" via the public group listing',
|
|
self::FolderCreated => 'Created the folder ":subject"',
|
|
self::FolderRenamed => 'Renamed the folder ":subject"',
|
|
self::FolderMoved => 'Moved the folder ":subject"',
|
|
self::FolderDeleted => 'Deleted the folder ":name" and its contents',
|
|
self::FolderShared => 'Shared the folder ":subject" with :target',
|
|
self::FolderUnshared => 'Stopped sharing the folder ":subject" with :target',
|
|
self::FileMadePublic => 'Made the file ":subject" public',
|
|
self::FileMadePrivate => 'Made the file ":subject" private',
|
|
self::FolderMadePublic => 'Made the folder ":subject" public',
|
|
self::FolderMadePrivate => 'Made the folder ":subject" private',
|
|
self::UploadAborted => 'Cancelled uploading the file ":name"',
|
|
self::CommentPosted => 'Commented on the file ":subject"',
|
|
self::CommentPostedByVisitor => ':guest commented on the file ":subject"',
|
|
self::CommentEdited => 'Edited a comment on the file ":subject"',
|
|
self::CommentDeleted => 'Deleted a comment on the file ":subject"',
|
|
self::CommentApproved => 'Approved a comment on the file ":subject"',
|
|
self::FileImported => 'Imported the orphan file ":subject"',
|
|
self::OrphanFileDeleted => 'Deleted the orphan file ":name"',
|
|
self::OrphanFileAutoDeleted => 'Deleted the orphan file ":name"',
|
|
self::ExpiredFileDeleted => 'Deleted the expired file ":name"',
|
|
self::GroupMembershipLeft => 'Left the group ":subject"',
|
|
self::GroupMembershipRequested => 'Requested membership to the group ":subject"',
|
|
self::GroupMembershipApproved => 'Approved the membership of ":member" to the group ":subject"',
|
|
self::GroupMembershipDenied => 'Denied the membership of ":member" to the group ":subject"',
|
|
self::GroupCreated => 'Created the group ":subject"',
|
|
self::GroupUpdated => 'Updated the group ":subject"',
|
|
self::GroupDeleted => 'Deleted the group ":name"',
|
|
self::GroupMadePublic => 'Made the group ":subject" public',
|
|
self::GroupMadePrivate => 'Made the group ":subject" private',
|
|
self::GroupMemberAdded => 'Added ":member" to the group ":subject"',
|
|
self::GroupMemberRemoved => 'Removed ":member" from the group ":subject"',
|
|
self::RoleCreated => 'Created the role ":subject"',
|
|
self::RoleUpdated => 'Updated the role ":subject"',
|
|
self::RoleDeleted => 'Deleted the role ":name"',
|
|
self::CategoryCreated => 'Created the category ":subject"',
|
|
self::CategoryRenamed => 'Renamed the category to ":subject"',
|
|
self::CategoryDeleted => 'Deleted the category ":name"',
|
|
self::ClientCustomFieldCreated => 'Created the custom field ":name"',
|
|
self::ClientCustomFieldUpdated => 'Updated the custom field ":name"',
|
|
self::ClientCustomFieldDeleted => 'Deleted the custom field ":name"',
|
|
self::ProfileUpdated => 'Updated their profile information',
|
|
self::PasswordUpdated => 'Changed their password',
|
|
self::TwoFactorEnabled => 'Enabled two-factor authentication',
|
|
self::TwoFactorDisabled => 'Disabled two-factor authentication',
|
|
self::TwoFactorRecoveryCodesRegenerated => 'Regenerated two-factor recovery codes',
|
|
self::TwoFactorReset => 'Removed two-factor authentication from ":subject"',
|
|
self::ApiTokenCreated => 'Created the API token ":token_name"',
|
|
self::ApiTokenUpdated => 'Updated the API token ":token_name"',
|
|
self::ApiTokenRevoked => 'Revoked the API token ":token_name"',
|
|
self::CustomAssetCreated => 'Created the custom asset ":subject"',
|
|
self::CustomAssetUpdated => 'Updated the custom asset ":subject"',
|
|
self::CustomAssetDeleted => 'Deleted the custom asset ":name"',
|
|
self::CustomAssetEnabled => 'Enabled the custom asset ":subject"',
|
|
self::CustomAssetDisabled => 'Disabled the custom asset ":subject"',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generic description for filter dropdowns (also the translation key).
|
|
*/
|
|
public function description(): string
|
|
{
|
|
return match ($this) {
|
|
self::SetupCompleted => 'ProjectSend was installed',
|
|
self::ApplicationUpdated => 'ProjectSend was updated to a new version',
|
|
self::SettingsUpdated => 'System settings were updated',
|
|
self::Login => 'Logged in',
|
|
self::Logout => 'Logged out',
|
|
self::UserCreated => 'An account was created',
|
|
self::UserUpdated => 'An account was updated',
|
|
self::UserDeleted => 'An account was deleted',
|
|
self::AccountConvertedToClient => 'A staff account was converted to a client',
|
|
self::AccountConvertedToStaff => 'A client account was converted to staff',
|
|
self::LdapClientProvisioned => 'A client account was created from the directory',
|
|
self::SocialClientProvisioned => 'A client account was created from :provider',
|
|
self::SocialAccountLinked => 'A :provider account was connected',
|
|
self::SocialAccountUnlinked => 'A :provider account was disconnected',
|
|
self::UserActivated => 'An account was activated',
|
|
self::UserDeactivated => 'An account was deactivated',
|
|
self::AccountErased => 'An account was permanently erased',
|
|
self::AccountContentCascadeDeleted => 'A deleted account\'s files and folders were deleted',
|
|
self::AccountContentReassigned => 'A deleted account\'s files and folders were reassigned',
|
|
self::ClientSelfRegistered => 'A client registered an account',
|
|
self::ClientApproved => 'A client account request was approved',
|
|
self::ClientDenied => 'A client account request was denied',
|
|
self::ClientInvited => 'A client was invited to register an account',
|
|
self::ClientInvitationRedeemed => 'A client registered an account from an invitation',
|
|
self::ClientInvitationRevoked => 'An invitation was revoked before it was used',
|
|
self::ClientInvitationResent => 'An invited person asked for a replacement link',
|
|
self::FileUploaded => 'A file was uploaded',
|
|
self::FileUpdated => 'A file was updated',
|
|
self::FileDeleted => 'A file was deleted',
|
|
self::FileDownloaded => 'A file was downloaded',
|
|
self::FilePreviewed => 'A file was previewed',
|
|
self::FileAssigned => 'A file was assigned',
|
|
self::FileUnassigned => 'A file assignment was removed',
|
|
self::FileVersionLinked => 'A file was marked as a revision of another',
|
|
self::FileVersionUnlinked => 'A file version link was removed',
|
|
self::ShareLinkCreated => 'A public link was created',
|
|
self::ShareLinkRevoked => 'A public link was revoked',
|
|
self::ShareLinkDownloaded => 'A file was downloaded via a public link',
|
|
self::PublicFileDownloaded => 'A file was downloaded via the public group listing',
|
|
self::PublicFilePreviewed => 'A file was previewed via the public group listing',
|
|
self::FolderCreated => 'A folder was created',
|
|
self::FolderRenamed => 'A folder was renamed',
|
|
self::FolderMoved => 'A folder was moved',
|
|
self::FolderDeleted => 'A folder was deleted',
|
|
self::FolderShared => 'A folder was shared',
|
|
self::FolderUnshared => 'A folder was unshared',
|
|
self::FileMadePublic => 'A file was made public',
|
|
self::FileMadePrivate => 'A file was made private',
|
|
self::FolderMadePublic => 'A folder was made public',
|
|
self::FolderMadePrivate => 'A folder was made private',
|
|
self::UploadAborted => 'An upload was cancelled',
|
|
self::CommentPosted => 'A comment was posted on a file',
|
|
self::CommentPostedByVisitor => 'A visitor commented on a file',
|
|
self::CommentEdited => 'A comment was edited',
|
|
self::CommentDeleted => 'A comment was deleted',
|
|
self::CommentApproved => 'A comment was approved',
|
|
self::FileImported => 'An orphan file was imported',
|
|
self::OrphanFileDeleted => 'An orphan file was deleted',
|
|
self::OrphanFileAutoDeleted => 'An orphan file was automatically deleted after its retention grace period passed',
|
|
self::ExpiredFileDeleted => 'An expired file was automatically deleted after its retention grace period passed',
|
|
self::GroupMembershipLeft => 'A client left a group',
|
|
self::GroupMembershipRequested => 'A group membership was requested',
|
|
self::GroupMembershipApproved => 'A group membership request was approved',
|
|
self::GroupMembershipDenied => 'A group membership request was denied',
|
|
self::GroupCreated => 'A group was created',
|
|
self::GroupUpdated => 'A group was updated',
|
|
self::GroupDeleted => 'A group was deleted',
|
|
self::GroupMadePublic => 'A group was made public',
|
|
self::GroupMadePrivate => 'A group was made private',
|
|
self::GroupMemberAdded => 'A client was added to a group',
|
|
self::GroupMemberRemoved => 'A client was removed from a group',
|
|
self::RoleCreated => 'A role was created',
|
|
self::RoleUpdated => 'A role was updated',
|
|
self::RoleDeleted => 'A role was deleted',
|
|
self::CategoryCreated => 'A category was created',
|
|
self::CategoryRenamed => 'A category was renamed',
|
|
self::CategoryDeleted => 'A category was deleted',
|
|
self::ClientCustomFieldCreated => 'A custom field was created',
|
|
self::ClientCustomFieldUpdated => 'A custom field was updated',
|
|
self::ClientCustomFieldDeleted => 'A custom field was deleted',
|
|
self::ProfileUpdated => 'Profile information was updated',
|
|
self::PasswordUpdated => 'The account password was changed',
|
|
self::TwoFactorEnabled => 'Two-factor authentication was enabled',
|
|
self::TwoFactorDisabled => 'Two-factor authentication was disabled',
|
|
self::TwoFactorRecoveryCodesRegenerated => 'Two-factor recovery codes were regenerated',
|
|
self::TwoFactorReset => 'Two-factor authentication was removed by an administrator',
|
|
self::ApiTokenCreated => 'An API token was created',
|
|
self::ApiTokenUpdated => 'An API token was updated',
|
|
self::ApiTokenRevoked => 'An API token was revoked',
|
|
self::CustomAssetCreated => 'A custom asset was created',
|
|
self::CustomAssetUpdated => 'A custom asset was updated',
|
|
self::CustomAssetDeleted => 'A custom asset was deleted',
|
|
self::CustomAssetEnabled => 'A custom asset was enabled',
|
|
self::CustomAssetDisabled => 'A custom asset was disabled',
|
|
};
|
|
}
|
|
}
|