mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-23 03:53:21 +00:00
Add an activity endpoint, so an integration can react rather than poll for shape
Every list in /api/v1 answers "what is there now". Nothing answered "what happened", and for the two events people most want to act on there was nowhere to look at all. Sharing a file writes an assignment row and never touches the file, so no amount of polling /files?updated_since= will ever show a share. A download is recorded only in the activity log. So the most requested automations for a file-sharing product — tell me when a client gets a file, tell me when they open it — were not possible to build. GET /api/v1/activity is one feed rather than one endpoint per event, because the log already records every one of them and a caller filtering by action gets whatever the application grows later without waiting for us to expose it. It reuses what already exists: view_actions_log is the permission the activity screen uses, and ActivityLogScope narrows the rows the same way, so a staff member limited to their assigned clients cannot read the whole installation's log through a token when the screen would not show it. Two deliberate limits. Class names never reach the wire — subject.type is a stable public string, or moving a model between namespaces would be a breaking change to a frozen contract. And no ip_address, though the column exists and the screen shows it: a person looking at a log has decided to look, where an integration streams every row to somebody else's servers by default. PollingQuery grew an optional column so it can walk a table that is appended to rather than edited. The parameter stays updated_since everywhere, because on an append-only log the two timestamps are the same thing and one shape learned once is worth more than a second name.
This commit is contained in:
@@ -29,6 +29,13 @@ use Illuminate\Support\Carbon;
|
||||
* one forever. The cost is re-seeing the boundary row, which a client
|
||||
* de-duplicates by id — the safe direction of the trade.
|
||||
*
|
||||
* Some tables have no `updated_at` because their rows are never edited —
|
||||
* the activity log is one. They pass their own column instead. The
|
||||
* *parameter* stays `updated_since` for every endpoint even so: the
|
||||
* shape being learned once is worth more than a second name that would
|
||||
* behave identically, since on an append-only table the two timestamps
|
||||
* are the same thing.
|
||||
*
|
||||
* Known limitation, documented rather than papered over: polling cannot
|
||||
* observe deletions. A soft-deleted row simply stops appearing. Webhooks
|
||||
* are the fix, and are deliberately a later phase.
|
||||
@@ -39,9 +46,11 @@ class PollingQuery
|
||||
* @template TModel of Model
|
||||
*
|
||||
* @param Builder<TModel> $query
|
||||
* @param string $column the timestamp to walk, for a table whose
|
||||
* rows are appended rather than edited
|
||||
* @return CursorPaginator<int, TModel>
|
||||
*/
|
||||
public function paginate(Request $request, Builder $query, string $table): CursorPaginator
|
||||
public function paginate(Request $request, Builder $query, string $table, string $column = 'updated_at'): CursorPaginator
|
||||
{
|
||||
$since = $request->query('updated_since');
|
||||
|
||||
@@ -53,11 +62,11 @@ class PollingQuery
|
||||
// a polling client would see an empty result forever instead of
|
||||
// an error. Carbon also normalises the offset into the app's
|
||||
// timezone, so a caller in any timezone gets the same rows.
|
||||
$query->where("{$table}.updated_at", '>=', Carbon::parse($since)->timezone(config('app.timezone')))
|
||||
->orderBy("{$table}.updated_at")
|
||||
$query->where("{$table}.{$column}", '>=', Carbon::parse($since)->timezone(config('app.timezone')))
|
||||
->orderBy("{$table}.{$column}")
|
||||
->orderBy("{$table}.id");
|
||||
} else {
|
||||
$query->orderByDesc("{$table}.updated_at")
|
||||
$query->orderByDesc("{$table}.{$column}")
|
||||
->orderByDesc("{$table}.id");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Audit\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Api\Support\PollingQuery;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLog;
|
||||
use App\Modules\Audit\ActivityLogScope;
|
||||
use App\Modules\Audit\Http\Resources\Api\ActivityResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* What has happened in this installation.
|
||||
*
|
||||
* The endpoint automation tools actually need. Every other list here
|
||||
* answers "what is there now"; a caller that wants to *react* — post to
|
||||
* Slack when a file is shared, add a row when a client downloads one —
|
||||
* needs to know that something happened, and the shape of the thing
|
||||
* afterwards does not say. Sharing a file writes an assignment row and
|
||||
* never touches the file, so polling the file list cannot see it at all.
|
||||
*
|
||||
* One feed rather than one endpoint per event, because the log already
|
||||
* records every one of them and a caller filtering by `action` gets any
|
||||
* event the application ever grows without waiting for an endpoint.
|
||||
*/
|
||||
class ActivityController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PollingQuery $polling,
|
||||
private readonly ActivityLogScope $scope,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* List activity, newest first.
|
||||
*
|
||||
* Filter by `action` — repeat the parameter for more than one, as
|
||||
* `?action[]=file.assigned&action[]=file.downloaded`. `subject_type`
|
||||
* narrows to one kind of thing (`file`, `user`, `group`, …).
|
||||
*
|
||||
* Entries are never edited, so `updated_since` walks the moment each
|
||||
* one was recorded. Everything else about polling is the shape every
|
||||
* list endpoint here shares.
|
||||
*
|
||||
* Scoped to what the caller may read: a staff member limited to their
|
||||
* assigned clients sees entries about their own library and their own
|
||||
* actions, never the whole installation's.
|
||||
*/
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$filters = $request->validate($this->polling->rules() + [
|
||||
'action' => ['nullable', 'array'],
|
||||
'action.*' => [Rule::enum(Action::class)],
|
||||
'subject_type' => ['nullable', 'string', 'max:64'],
|
||||
]);
|
||||
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
$query = $this->scope->apply(ActivityLog::query(), $viewer);
|
||||
|
||||
if (($filters['action'] ?? []) !== []) {
|
||||
$query->whereIn('action', $filters['action']);
|
||||
}
|
||||
|
||||
if (($filters['subject_type'] ?? null) !== null) {
|
||||
$query->where('subject_type', $this->subjectClass($filters['subject_type']));
|
||||
}
|
||||
|
||||
// created_at, not updated_at: the log is appended to and never
|
||||
// edited, and has no updated_at column to walk.
|
||||
return ActivityResource::collection(
|
||||
$this->polling->paginate($request, $query, 'activity_log', 'created_at')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The public name for a kind of subject, back to the class the column
|
||||
* actually holds. An unknown name matches nothing rather than
|
||||
* everything — a filter that silently ignores what it was given would
|
||||
* hand back the whole log to a caller who asked for one slice of it.
|
||||
*/
|
||||
private function subjectClass(string $type): string
|
||||
{
|
||||
return array_search($type, ActivityResource::subjects(), true) ?: '__no_such_subject__';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Audit\Http\Resources\Api;
|
||||
|
||||
use App\Modules\Audit\ActivityLog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* One entry in the activity log, as an integration reads it.
|
||||
*
|
||||
* Deliberately not ActivityPresenter's shape. That one exists to render a
|
||||
* sentence, so it hands back a template and the words to slot into it —
|
||||
* right for a screen, useless to a caller that wants to branch on what
|
||||
* happened. Here the action is a key, the subject is an object, and the
|
||||
* specifics stay in `context`.
|
||||
*
|
||||
* @mixin ActivityLog
|
||||
*/
|
||||
class ActivityResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Class names are internal structure and must never reach the wire:
|
||||
* moving a model between namespaces would otherwise be a breaking API
|
||||
* change, and `/api/v1` is a frozen contract. These strings are the
|
||||
* contract instead — add to this map when a new kind of thing becomes
|
||||
* a subject, and never rename an entry in it.
|
||||
*
|
||||
* @var array<class-string, string>
|
||||
*/
|
||||
private const SUBJECTS = [
|
||||
\App\Models\User::class => 'user',
|
||||
\App\Modules\Files\Models\File::class => 'file',
|
||||
\App\Modules\Files\Models\Folder::class => 'folder',
|
||||
\App\Modules\Files\Models\Category::class => 'category',
|
||||
\App\Modules\Groups\Models\Group::class => 'group',
|
||||
\App\Modules\Identity\Models\Role::class => 'role',
|
||||
\App\Modules\Clients\Models\ClientCustomField::class => 'client_custom_field',
|
||||
];
|
||||
|
||||
/**
|
||||
* The map, for the controller's reverse lookup.
|
||||
*
|
||||
* @return array<class-string, string>
|
||||
*/
|
||||
public static function subjects(): array
|
||||
{
|
||||
return self::SUBJECTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'action' => $this->action->value,
|
||||
'created_at' => $this->created_at->toIso8601String(),
|
||||
|
||||
// Snapshots, not joins. The actor may since have been deleted,
|
||||
// and the entry still has to say who it was.
|
||||
'actor' => $this->actor_id === null && $this->actor_name === null ? null : [
|
||||
'id' => $this->actor_id,
|
||||
'name' => $this->actor_name,
|
||||
'type' => $this->actor_type,
|
||||
],
|
||||
|
||||
// How it arrived: a person in the browser, an integration, a
|
||||
// visitor with no account, or the installation itself.
|
||||
'origin' => $this->origin->value,
|
||||
|
||||
'subject' => $this->subject_type === null ? null : [
|
||||
'type' => self::SUBJECTS[$this->subject_type] ?? 'other',
|
||||
'id' => $this->subject_id,
|
||||
'name' => $this->subject_name,
|
||||
],
|
||||
|
||||
// Whatever the action recorded beyond its subject — who a file
|
||||
// was shared with, how many files a cascade removed. Shape
|
||||
// varies by action and is documented per action rather than
|
||||
// here.
|
||||
'context' => $this->context ?? [],
|
||||
|
||||
// ip_address is deliberately absent. It is stored for some
|
||||
// actions and shown on the activity screen, but handing a
|
||||
// client's IP to an automation tool is a privacy expansion
|
||||
// with no matching use — see docs/api-todo.md.
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,59 @@ deletions, that is what webhooks will be for; they are not built yet.
|
||||
|
||||
---
|
||||
|
||||
## Reacting to things that happen
|
||||
|
||||
Every list above answers "what is there now". `GET /api/v1/activity` answers "what happened", which
|
||||
is what an automation tool actually needs — and for two of the most useful events it is the only
|
||||
place to look.
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer YOUR_TOKEN" \
|
||||
"https://your-install.example.com/api/v1/activity?action[]=file.assigned"
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1284,
|
||||
"action": "file.assigned",
|
||||
"created_at": "2026-08-25T09:14:02+00:00",
|
||||
"actor": { "id": 3, "name": "Dana", "type": "staff" },
|
||||
"origin": "ui",
|
||||
"subject": { "type": "file", "id": 128, "name": "October invoice" },
|
||||
"context": { "target": "Acme Ltd" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Sharing a file leaves no mark on the file.** It writes an assignment, and the file's own
|
||||
`updated_at` does not move — so polling `/files?updated_since=` will never show you a share, no
|
||||
matter how often you ask. The same goes for downloads, which are recorded here and nowhere else.
|
||||
|
||||
Repeat `action` for more than one: `?action[]=file.assigned&action[]=file.downloaded`. An action
|
||||
this installation has never heard of is a `422` rather than being quietly dropped, because ignoring
|
||||
it would hand back the whole log to a caller who asked for one slice. `subject_type` narrows to one
|
||||
kind of thing — `file`, `folder`, `user`, `group`, `category`, `role`.
|
||||
|
||||
Polling works as it does everywhere else. Entries are never edited, so `updated_since` walks the
|
||||
moment each was recorded; the two mean the same thing on a log that is only ever appended to.
|
||||
|
||||
Needs `view_actions_log`, the same permission the activity screen uses, and the same scoping: a
|
||||
staff member limited to their assigned clients sees their own library and their own actions, never
|
||||
the whole installation's.
|
||||
|
||||
**No IP addresses.** Some entries record one, and the activity screen shows it. It is left out here
|
||||
on purpose: handing a client's IP to an automation tool is a privacy question nobody asked to have
|
||||
answered for them.
|
||||
|
||||
Two things this still cannot tell you. **Deletions** — a deleted row stops appearing, and nothing
|
||||
marks the moment; that is what webhooks would be for. And anything the log does not record, which
|
||||
is deliberately less than everything.
|
||||
|
||||
---
|
||||
|
||||
## Uploading
|
||||
|
||||
Two ways, and the right one depends on the file.
|
||||
|
||||
+45
-8
@@ -86,10 +86,21 @@ which is exactly what Zapier needs to tell new things from old ones.
|
||||
|
||||
| What you want to know | URL |
|
||||
|---|---|
|
||||
| A file was added | `https://your-install.example.com/api/v1/files` |
|
||||
| A client account was created | `https://your-install.example.com/api/v1/clients` |
|
||||
| A comment is waiting for approval | `https://your-install.example.com/api/v1/comments/pending` |
|
||||
| A group was created | `https://your-install.example.com/api/v1/groups` |
|
||||
| **A file was shared with a client** | `…/api/v1/activity?action[]=file.assigned` |
|
||||
| **A client downloaded a file** | `…/api/v1/activity?action[]=file.downloaded` |
|
||||
| **Somebody left a comment** | `…/api/v1/activity?action[]=comment.posted` |
|
||||
| Anything at all happened | `…/api/v1/activity` |
|
||||
| A file was added | `…/api/v1/files` |
|
||||
| A client account was created | `…/api/v1/clients` |
|
||||
| A comment is waiting for approval | `…/api/v1/comments/pending` |
|
||||
| A group was created | `…/api/v1/groups` |
|
||||
|
||||
The first three are the ones people usually want, and they only work through
|
||||
`/api/v1/activity`. Sharing a file writes an assignment and leaves the file itself untouched, so
|
||||
polling the file list will never show you a share; downloads are recorded in the activity log and
|
||||
nowhere else. Repeat `action[]` to watch for more than one kind of thing at once.
|
||||
|
||||
Watching activity needs a token with **view activity log** ticked.
|
||||
|
||||
In the Zapier step, set:
|
||||
|
||||
@@ -100,6 +111,28 @@ In the Zapier step, set:
|
||||
|
||||
### What comes back
|
||||
|
||||
From `/api/v1/activity`:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1284,
|
||||
"action": "file.assigned",
|
||||
"created_at": "2026-08-25T09:14:02+00:00",
|
||||
"actor": { "id": 3, "name": "Dana", "type": "staff" },
|
||||
"subject": { "type": "file", "id": 128, "name": "October invoice" },
|
||||
"context": { "target": "Acme Ltd" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
So a Slack message can read *"Dana shared October invoice with Acme Ltd"* using `actor.name`,
|
||||
`subject.name` and `context.target`.
|
||||
|
||||
From `/api/v1/files`:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
@@ -208,14 +241,18 @@ Only clients can be group members. Passing a staff account is refused.
|
||||
|
||||
## Three complete examples
|
||||
|
||||
### 1. Tell the team in Slack when a file arrives
|
||||
### 1. Tell the team in Slack when a client downloads something
|
||||
|
||||
- **Trigger**: Webhooks by Zapier → Retrieve Poll → `GET /api/v1/files`
|
||||
- **Trigger**: Webhooks by Zapier → Retrieve Poll →
|
||||
`GET /api/v1/activity?action[]=file.downloaded`
|
||||
- **Action**: Slack → Send Channel Message
|
||||
|
||||
In the Slack message, use the `name` and `size` fields from the trigger. Something like:
|
||||
Use the fields from the trigger:
|
||||
|
||||
> New file in ProjectSend: **{{name}}** ({{size}} bytes)
|
||||
> **{{actor__name}}** downloaded **{{subject__name}}**
|
||||
|
||||
This is the one people ask for most, and it is only possible through the activity feed — a download
|
||||
leaves no trace on the file itself.
|
||||
|
||||
### 2. Turn a form submission into a client account
|
||||
|
||||
|
||||
@@ -16,6 +16,175 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/activity": {
|
||||
"get": {
|
||||
"operationId": "activity.index",
|
||||
"description": "Filter by `action` \u2014 repeat the parameter for more than one, as\n`?action[]=file.assigned&action[]=file.downloaded`. `subject_type`\nnarrows to one kind of thing (`file`, `user`, `group`, \u2026).\n\nEntries are never edited, so `updated_since` walks the moment each\none was recorded. Everything else about polling is the shape every\nlist endpoint here shares.\n\nScoped to what the caller may read: a staff member limited to their\nassigned clients sees entries about their own library and their own\nactions, never the whole installation's.\n\nRequires a token with the ability: `view_actions_log`.",
|
||||
"summary": "List activity, newest first",
|
||||
"tags": [
|
||||
"Activity"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "updated_since",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"format": "date-time"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "per_page",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 1,
|
||||
"maximum": 100
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "cursor",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "action[]",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Action"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "subject_type",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 64
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "created_at, not updated_at: the log is appended to and never\nedited, and has no updated_at column to walk.\n\n\n\nPaginated set of `ActivityResource`",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ActivityResource"
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"first": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"last": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"prev": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"next": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"first",
|
||||
"last",
|
||||
"prev",
|
||||
"next"
|
||||
]
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Base path for paginator generated URLs."
|
||||
},
|
||||
"per_page": {
|
||||
"type": "integer",
|
||||
"description": "Number of items shown per page.",
|
||||
"minimum": 0
|
||||
},
|
||||
"next_cursor": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The \"cursor\" that points to the next set of items."
|
||||
},
|
||||
"prev_cursor": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The \"cursor\" that points to the previous set of items."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"path",
|
||||
"per_page",
|
||||
"next_cursor",
|
||||
"prev_cursor"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"links",
|
||||
"meta"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#/components/responses/ValidationException"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/AuthenticationException"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/uploads": {
|
||||
"post": {
|
||||
"operationId": "uploads.store",
|
||||
@@ -3308,6 +3477,206 @@
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"Action": {
|
||||
"type": "string",
|
||||
"description": "Every action the activity log can record \u2014 v2's replacement for v1's ~45 numbered action types in ActionsLog. Modules add their own cases (file.uploaded, group.created, \u2026) as they land; the string values are stable identifiers stored in the database.\n",
|
||||
"enum": [
|
||||
"setup.completed",
|
||||
"settings.updated",
|
||||
"application.updated",
|
||||
"auth.login",
|
||||
"auth.logout",
|
||||
"user.created",
|
||||
"user.updated",
|
||||
"user.deleted",
|
||||
"user.activated",
|
||||
"user.deactivated",
|
||||
"account.erased",
|
||||
"account_content.cascade_deleted",
|
||||
"account_content.reassigned",
|
||||
"account.converted_to_client",
|
||||
"account.converted_to_staff",
|
||||
"client.self_registered",
|
||||
"ldap.client_provisioned",
|
||||
"social.client_provisioned",
|
||||
"social.account_linked",
|
||||
"social.account_unlinked",
|
||||
"client.approved",
|
||||
"client.denied",
|
||||
"file.uploaded",
|
||||
"file.updated",
|
||||
"file.deleted",
|
||||
"file.downloaded",
|
||||
"file.previewed",
|
||||
"file.assigned",
|
||||
"file.unassigned",
|
||||
"file.version_linked",
|
||||
"file.version_unlinked",
|
||||
"share_link.created",
|
||||
"share_link.revoked",
|
||||
"share_link.downloaded",
|
||||
"public_file.downloaded",
|
||||
"public_file.previewed",
|
||||
"folder.created",
|
||||
"folder.renamed",
|
||||
"folder.moved",
|
||||
"folder.deleted",
|
||||
"folder.shared",
|
||||
"folder.unshared",
|
||||
"file.made_public",
|
||||
"file.made_private",
|
||||
"folder.made_public",
|
||||
"folder.made_private",
|
||||
"upload.aborted",
|
||||
"file.imported",
|
||||
"orphan_file.deleted",
|
||||
"orphan_file.auto_deleted",
|
||||
"file.expired_deleted",
|
||||
"group.membership_left",
|
||||
"group.membership_requested",
|
||||
"group.membership_approved",
|
||||
"group.membership_denied",
|
||||
"group.created",
|
||||
"group.updated",
|
||||
"group.deleted",
|
||||
"group.made_public",
|
||||
"group.made_private",
|
||||
"group.member_added",
|
||||
"group.member_removed",
|
||||
"role.created",
|
||||
"role.updated",
|
||||
"role.deleted",
|
||||
"category.created",
|
||||
"category.renamed",
|
||||
"category.deleted",
|
||||
"client_custom_field.created",
|
||||
"client_custom_field.updated",
|
||||
"client_custom_field.deleted",
|
||||
"profile.updated",
|
||||
"password.updated",
|
||||
"two_factor.enabled",
|
||||
"two_factor.disabled",
|
||||
"two_factor.recovery_codes_regenerated",
|
||||
"two_factor.reset",
|
||||
"api_token.created",
|
||||
"api_token.updated",
|
||||
"api_token.revoked",
|
||||
"custom_asset.created",
|
||||
"custom_asset.updated",
|
||||
"custom_asset.deleted",
|
||||
"custom_asset.enabled",
|
||||
"custom_asset.disabled",
|
||||
"comment.posted",
|
||||
"comment.posted_by_visitor",
|
||||
"comment.edited",
|
||||
"comment.deleted",
|
||||
"comment.approved"
|
||||
],
|
||||
"title": "Action"
|
||||
},
|
||||
"ActivityResource": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"action": {
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"actor": {
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"description": "Snapshots, not joins. The actor may since have been deleted,\nand the entry still has to say who it was.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"origin": {
|
||||
"type": "string",
|
||||
"description": "How it arrived: a person in the browser, an integration, a\nvisitor with no account, or the installation itself."
|
||||
},
|
||||
"subject": {
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"other"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"id": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"id",
|
||||
"name"
|
||||
]
|
||||
},
|
||||
"context": {
|
||||
"type": "array",
|
||||
"description": "Whatever the action recorded beyond its subject \u2014 who a file\nwas shared with, how many files a cascade removed. Shape\nvaries by action and is documented per action rather than\nhere.",
|
||||
"items": {}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"action",
|
||||
"created_at",
|
||||
"actor",
|
||||
"origin",
|
||||
"subject",
|
||||
"context"
|
||||
],
|
||||
"title": "ActivityResource"
|
||||
},
|
||||
"ClientResource": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Modules\Api\Events\ApiModule;
|
||||
use App\Modules\Api\Events\RegisteringApiModules;
|
||||
use App\Modules\Api\Http\Controllers\CurrentTokenController;
|
||||
use App\Modules\Api\Http\Controllers\MeController;
|
||||
use App\Modules\Audit\Http\Controllers\Api\ActivityController;
|
||||
use App\Modules\Api\Http\Controllers\OpenApiController;
|
||||
use App\Modules\Clients\Http\Controllers\Api\ClientsController;
|
||||
use App\Modules\Comments\Http\Controllers\Api\CommentModerationController;
|
||||
@@ -274,6 +275,27 @@ Route::middleware(['auth:sanctum', 'api-active', 'staff-token'])->group(function
|
||||
->name('api.groups.members.destroy');
|
||||
});
|
||||
|
||||
/*
|
||||
|----------------------------------------------------------------------
|
||||
| Activity
|
||||
|----------------------------------------------------------------------
|
||||
|
|
||||
| The one endpoint that answers "what happened", rather than "what is
|
||||
| there now". An integration reacting to events has nothing else to
|
||||
| poll: sharing a file writes an assignment row and leaves the file
|
||||
| untouched, and a download is only ever recorded here, so neither is
|
||||
| visible from any other list.
|
||||
|
|
||||
| `view_actions_log` is the same permission the activity screen uses,
|
||||
| and ActivityLogScope narrows the rows the same way it does there —
|
||||
| a staff member limited to their assigned clients must not read the
|
||||
| whole installation's log through a token when the screen would not
|
||||
| show it to them.
|
||||
|
|
||||
*/
|
||||
Route::get('activity', [ActivityController::class, 'index'])
|
||||
->middleware('token-can:view_actions_log')->name('api.activity.index');
|
||||
|
||||
/*
|
||||
|----------------------------------------------------------------------
|
||||
| Module endpoints
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use App\Modules\Identity\Permissions\Permission;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| GET /api/v1/activity
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The endpoint an automation tool reacts to. Every other list says what is
|
||||
| there now; this one says what happened, which is the only way to notice
|
||||
| a share or a download at all — neither leaves a trace on any other list.
|
||||
|
|
||||
*/
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake('files');
|
||||
$this->staff = User::factory()->create();
|
||||
});
|
||||
|
||||
function activityToken(User $user, array $abilities = [Permission::ViewActionsLog->value]): string
|
||||
{
|
||||
return $user->createToken('Zapier', $abilities)->plainTextToken;
|
||||
}
|
||||
|
||||
test('the feed lists what happened, newest first', function () {
|
||||
$logger = app(ActivityLogger::class);
|
||||
$logger->log(Action::FileUploaded, $this->staff, context: ['name' => 'older']);
|
||||
$logger->log(Action::FileDownloaded, $this->staff, context: ['name' => 'newer']);
|
||||
|
||||
$response = $this->withToken(activityToken($this->staff))
|
||||
->getJson('/api/v1/activity')
|
||||
->assertOk();
|
||||
|
||||
// Newest first is what a polling trigger reads: it takes the first
|
||||
// page and de-duplicates by id.
|
||||
expect($response->json('data.0.action'))->toBe('file.downloaded')
|
||||
->and($response->json('data.1.action'))->toBe('file.uploaded');
|
||||
});
|
||||
|
||||
test('sharing a file is visible here and nowhere else', function () {
|
||||
// The reason this endpoint exists. FileSharing::assign() writes an
|
||||
// assignment row and never touches the file, so a caller polling
|
||||
// /files?updated_since= sees nothing at all.
|
||||
$client = User::factory()->client()->create();
|
||||
$file = File::factory()->create(['uploaded_by' => $this->staff->id]);
|
||||
|
||||
$token = activityToken($this->staff, [
|
||||
Permission::ViewActionsLog->value,
|
||||
Permission::EditFiles->value,
|
||||
Permission::Upload->value,
|
||||
]);
|
||||
|
||||
$untouched = $file->fresh()->updated_at;
|
||||
|
||||
$this->withToken($token)
|
||||
->postJson("/api/v1/files/{$file->id}/assignments", ['type' => 'client', 'id' => $client->id])
|
||||
->assertSuccessful();
|
||||
|
||||
// The file itself is exactly as it was, so no amount of polling
|
||||
// /files?updated_since= will ever surface the share.
|
||||
expect($file->fresh()->updated_at->equalTo($untouched))->toBeTrue();
|
||||
|
||||
$activity = $this->withToken($token)
|
||||
->getJson('/api/v1/activity?action[]='.Action::FileAssigned->value)
|
||||
->assertOk();
|
||||
|
||||
expect($activity->json('data.0.action'))->toBe('file.assigned')
|
||||
->and($activity->json('data.0.subject.type'))->toBe('file')
|
||||
->and($activity->json('data.0.subject.id'))->toBe($file->id);
|
||||
});
|
||||
|
||||
test('entries can be narrowed to the actions a caller cares about', function () {
|
||||
$logger = app(ActivityLogger::class);
|
||||
$logger->log(Action::FileUploaded, $this->staff);
|
||||
$logger->log(Action::FileDownloaded, $this->staff);
|
||||
$logger->log(Action::UserCreated, $this->staff);
|
||||
|
||||
$response = $this->withToken(activityToken($this->staff))
|
||||
->getJson('/api/v1/activity?action[]=file.downloaded&action[]=user.created')
|
||||
->assertOk();
|
||||
|
||||
expect(collect($response->json('data'))->pluck('action')->sort()->values()->all())
|
||||
->toBe(['file.downloaded', 'user.created']);
|
||||
});
|
||||
|
||||
test('an action nobody has heard of is refused, not ignored', function () {
|
||||
// Silently ignoring it would hand back the whole log to a caller who
|
||||
// asked for one slice of it.
|
||||
$this->withToken(activityToken($this->staff))
|
||||
->getJson('/api/v1/activity?action[]=file.teleported')
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
test('an unknown subject type matches nothing rather than everything', function () {
|
||||
app(ActivityLogger::class)->log(Action::FileUploaded, $this->staff);
|
||||
|
||||
$this->withToken(activityToken($this->staff))
|
||||
->getJson('/api/v1/activity?subject_type=spaceship')
|
||||
->assertOk()
|
||||
->assertJsonPath('data', []);
|
||||
});
|
||||
|
||||
test('subjects are named, never classed', function () {
|
||||
$group = Group::query()->create(['name' => 'Acme', 'public' => false]);
|
||||
app(ActivityLogger::class)->log(Action::GroupCreated, $this->staff, $group);
|
||||
|
||||
$response = $this->withToken(activityToken($this->staff))
|
||||
->getJson('/api/v1/activity')
|
||||
->assertOk();
|
||||
|
||||
// A class name on the wire would make moving a model between
|
||||
// namespaces a breaking API change.
|
||||
expect($response->json('data.0.subject.type'))->toBe('group')
|
||||
->and(json_encode($response->json()))->not->toContain('App\\Modules');
|
||||
});
|
||||
|
||||
test('polling walks forward without skipping or repeating', function () {
|
||||
$logger = app(ActivityLogger::class);
|
||||
$logger->log(Action::FileUploaded, $this->staff);
|
||||
|
||||
$token = activityToken($this->staff);
|
||||
$first = $this->withToken($token)->getJson('/api/v1/activity')->assertOk();
|
||||
$watermark = $first->json('data.0.created_at');
|
||||
|
||||
$logger->log(Action::FileDownloaded, $this->staff);
|
||||
|
||||
$next = $this->withToken($token)
|
||||
->getJson('/api/v1/activity?updated_since='.urlencode($watermark))
|
||||
->assertOk();
|
||||
|
||||
// The boundary row is inclusive on purpose and de-duplicated by id,
|
||||
// so the new entry must be there and must come last in the walk.
|
||||
expect(collect($next->json('data'))->pluck('action'))->toContain('file.downloaded');
|
||||
});
|
||||
|
||||
test('a token without the permission cannot read the log', function () {
|
||||
$this->withToken(activityToken($this->staff, [Permission::Upload->value]))
|
||||
->getJson('/api/v1/activity')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('an IP address is never handed to an integration', function () {
|
||||
app(ActivityLogger::class)->log(Action::FileDownloaded, $this->staff);
|
||||
|
||||
$response = $this->withToken(activityToken($this->staff))
|
||||
->getJson('/api/v1/activity')
|
||||
->assertOk();
|
||||
|
||||
expect($response->json('data.0'))->not->toHaveKey('ip_address');
|
||||
});
|
||||
Reference in New Issue
Block a user