mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-12 06:48:55 +00:00
6e47d76ba6
Client file sharing, rebuilt from the ground up: a private area per client, resumable uploads, folders, groups and categories, sharing with expiry dates and download limits, comments, file versions, an activity log, a REST API, and sixteen languages. This repository begins here. ProjectSend 2 was developed privately, and that development history is not published — the previous generation remains available, with its own history, at projectsend/legacy. Free software under the GNU General Public License v2, or (at your option) any later version.
305 lines
16 KiB
PHP
305 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
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\Api\Http\Controllers\OpenApiController;
|
|
use App\Modules\Clients\Http\Controllers\Api\ClientsController;
|
|
use App\Modules\Comments\Http\Controllers\Api\CommentModerationController;
|
|
use App\Modules\Comments\Http\Controllers\Api\FileCommentsController;
|
|
use App\Modules\Files\Http\Controllers\Api\FileAssignmentsController;
|
|
use App\Modules\Files\Http\Controllers\Api\FilesController;
|
|
use App\Modules\Files\Http\Controllers\Api\FileVersionsController as ApiFileVersionsController;
|
|
use App\Modules\Files\Http\Controllers\ChunkedUploadsController;
|
|
use App\Modules\Files\Http\Controllers\FileDownloadController;
|
|
use App\Modules\Groups\Http\Controllers\Api\GroupMembersController;
|
|
use App\Modules\Groups\Http\Controllers\Api\GroupsController;
|
|
use App\Modules\Identity\Http\Controllers\Api\RolesController;
|
|
use App\Modules\Identity\Http\Controllers\Api\UsersController;
|
|
use Illuminate\Support\Facades\Event;
|
|
use Illuminate\Support\Facades\Route;
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Public API v1
|
|
|--------------------------------------------------------------------------
|
|
|
|
|
| Mounted at /api/v1 (see bootstrap/app.php). Bearer tokens only — there is
|
|
| no session on these routes, by design; see config/sanctum.php.
|
|
|
|
|
| Every route below carries the same stack:
|
|
|
|
|
| auth:sanctum the token resolves to a user
|
|
| api-active that user has not since been deactivated
|
|
| staff-token that user is staff (the API is staff-only in v1)
|
|
|
|
|
| plus, per route, `token-can:<permission>` — which checks both that the
|
|
| token was granted the ability and that the user still holds it today.
|
|
|
|
|
| The permission on each route is the same one its routes/web.php twin
|
|
| carries. The staff/client boundary and the own/others split are mirrored
|
|
| from the web surface, never reinvented here.
|
|
|
|
|
*/
|
|
|
|
/*
|
|
* The only unauthenticated route in the API, and deliberately so: a client
|
|
* needs the specification before it can have a token, and the document
|
|
* describes the shape of the API without exposing anything about this
|
|
* installation. See OpenApiController.
|
|
*/
|
|
Route::get('openapi.json', OpenApiController::class)
|
|
->middleware('throttle:api')
|
|
->name('api.openapi');
|
|
|
|
Route::middleware(['auth:sanctum', 'api-active', 'staff-token'])->group(function () {
|
|
Route::get('me', MeController::class)->name('api.me');
|
|
|
|
// Revokes only the token that made the call. Revoking someone else's
|
|
// token is a web-only action: doing it here would let a leaked token
|
|
// lock the real owner out of their own integrations.
|
|
Route::delete('tokens/current', CurrentTokenController::class)->name('api.tokens.current.destroy');
|
|
|
|
/*
|
|
|----------------------------------------------------------------------
|
|
| Files (read)
|
|
|----------------------------------------------------------------------
|
|
|
|
|
| The ability list mirrors FilePolicy::view()'s staff branch rather
|
|
| than the web `files.index` route's `can:upload`. Those differ, and
|
|
| the policy is the right one to follow here: every row returned is
|
|
| individually view-authorized through ViewableFileScope, so requiring
|
|
| `upload` — a *write* permission — to list files a user is already
|
|
| allowed to read would be a stricter boundary than the app applies
|
|
| anywhere else. (Zip downloads already list by view rules alone.)
|
|
|
|
|
*/
|
|
Route::middleware('token-can:upload,edit_files,edit_others_files')->group(function () {
|
|
Route::get('files', [FilesController::class, 'index'])->name('api.files.index');
|
|
Route::get('files/{file}', [FilesController::class, 'show'])->name('api.files.show');
|
|
|
|
// The web controller, reused verbatim: it already authorizes with
|
|
// FilePolicy::view, writes the FileDownloaded activity entry (which
|
|
// honours Setting::DownloadIpLogging centrally), and answers with
|
|
// X-Accel-Redirect locally or a presigned redirect on external
|
|
// storage. Both work unchanged for an HTTP client, and duplicating
|
|
// that logic for the API is how the two would drift.
|
|
Route::get('files/{file}/download', FileDownloadController::class)->name('api.files.download');
|
|
});
|
|
|
|
/*
|
|
|----------------------------------------------------------------------
|
|
| Files (write)
|
|
|----------------------------------------------------------------------
|
|
|
|
|
| Each route carries the permission its routes/web.php twin carries,
|
|
| including the own/others split — which FilePolicy resolves per file,
|
|
| so both keys appear here and the policy decides which one applies.
|
|
|
|
|
*/
|
|
Route::post('files', [FilesController::class, 'store'])
|
|
->middleware(['token-can:upload', 'throttle:api-uploads'])
|
|
->name('api.files.store');
|
|
|
|
Route::patch('files/{file}', [FilesController::class, 'update'])
|
|
->middleware('token-can:edit_files,edit_others_files')
|
|
->name('api.files.update');
|
|
|
|
Route::delete('files/{file}', [FilesController::class, 'destroy'])
|
|
->middleware('token-can:delete_files,delete_others_files')
|
|
->name('api.files.destroy');
|
|
|
|
/*
|
|
|----------------------------------------------------------------------
|
|
| Chunked, resumable uploads
|
|
|----------------------------------------------------------------------
|
|
|
|
|
| Token-authenticated twins of the browser's Uppy-contract routes,
|
|
| reusing ChunkedUploadsController unchanged — it was already
|
|
| JSON-in/JSON-out, and it is where the real upload rules live (size
|
|
| setting, extension policy, storage quota re-checked against the
|
|
| assembled bytes, mime re-detected from those bytes).
|
|
|
|
|
| The part PUT carries `signed` as well as the token: the signature is
|
|
| the grant for that one part, exactly as on the web side. Unlike the
|
|
| web route it needs no CSRF exemption, because the API group has no
|
|
| CSRF to exempt it from.
|
|
|
|
|
*/
|
|
Route::middleware(['token-can:upload', 'throttle:api-uploads'])->group(function () {
|
|
Route::post('uploads', [ChunkedUploadsController::class, 'store'])->name('api.uploads.store');
|
|
Route::get('uploads/{session}/parts/{part}/sign', [ChunkedUploadsController::class, 'signPart'])
|
|
->name('api.uploads.parts.sign');
|
|
Route::put('uploads/{session}/parts/{part}', [ChunkedUploadsController::class, 'putPart'])
|
|
->middleware('signed')->name('api.uploads.parts.put');
|
|
Route::get('uploads/{session}/parts', [ChunkedUploadsController::class, 'listParts'])
|
|
->name('api.uploads.parts.index');
|
|
Route::post('uploads/{session}/complete', [ChunkedUploadsController::class, 'complete'])
|
|
->name('api.uploads.complete');
|
|
Route::delete('uploads/{session}', [ChunkedUploadsController::class, 'destroy'])
|
|
->name('api.uploads.destroy');
|
|
});
|
|
|
|
// Sharing is gated by "may edit this file" — see the controller.
|
|
Route::middleware('token-can:edit_files,edit_others_files')->group(function () {
|
|
Route::post('files/{file}/assignments', [FileAssignmentsController::class, 'store'])
|
|
->name('api.files.assignments.store');
|
|
Route::delete('files/{file}/assignments', [FileAssignmentsController::class, 'destroy'])
|
|
->name('api.files.assignments.destroy');
|
|
|
|
// Same gate as sharing, and for the same reason: linking moves this
|
|
// file's recipients onto the original, so it changes who can see
|
|
// that file too.
|
|
Route::put('files/{file}/version', [ApiFileVersionsController::class, 'store'])
|
|
->name('api.files.version.store');
|
|
Route::delete('files/{file}/version', [ApiFileVersionsController::class, 'destroy'])
|
|
->name('api.files.version.destroy');
|
|
});
|
|
|
|
/*
|
|
|----------------------------------------------------------------------
|
|
| Comments
|
|
|----------------------------------------------------------------------
|
|
|
|
|
| Reading and writing a comment is gated by "may see this file", the
|
|
| same three keys the file endpoints use — there is no comment
|
|
| permission, because who may comment is an installation setting rather
|
|
| than a per-role one (see App\Modules\Comments\CommentAuthors).
|
|
| `moderate_comments` gates the queue and approving; deleting stays
|
|
| under the file abilities because the policy also lets an author
|
|
| delete their own within the window, and that is not moderation.
|
|
|
|
|
*/
|
|
Route::middleware('token-can:upload,edit_files,edit_others_files')->group(function () {
|
|
Route::get('files/{file}/comments', [FileCommentsController::class, 'index'])->name('api.files.comments.index');
|
|
Route::post('files/{file}/comments', [FileCommentsController::class, 'store'])->name('api.files.comments.store');
|
|
Route::patch('comments/{comment}', [FileCommentsController::class, 'update'])->name('api.comments.update');
|
|
Route::delete('comments/{comment}', [FileCommentsController::class, 'destroy'])->name('api.comments.destroy');
|
|
});
|
|
|
|
// Registered before comments/{comment} would swallow "pending" as an id.
|
|
Route::middleware('token-can:moderate_comments')->group(function () {
|
|
Route::get('comments/pending', [CommentModerationController::class, 'index'])->name('api.comments.pending');
|
|
Route::post('comments/{comment}/approve', [CommentModerationController::class, 'approve'])->name('api.comments.approve');
|
|
});
|
|
|
|
/*
|
|
|----------------------------------------------------------------------
|
|
| Clients
|
|
|----------------------------------------------------------------------
|
|
|
|
|
| Permissions mirror routes/web.php exactly: listing is `manage_clients`,
|
|
| reading and editing one is `edit_clients`, and creating and deleting
|
|
| have keys of their own.
|
|
|
|
|
*/
|
|
Route::get('clients', [ClientsController::class, 'index'])
|
|
->middleware('token-can:manage_clients')->name('api.clients.index');
|
|
Route::get('clients/{client}', [ClientsController::class, 'show'])
|
|
->middleware('token-can:edit_clients')->name('api.clients.show');
|
|
Route::post('clients', [ClientsController::class, 'store'])
|
|
->middleware('token-can:create_clients')->name('api.clients.store');
|
|
Route::patch('clients/{client}', [ClientsController::class, 'update'])
|
|
->middleware('token-can:edit_clients')->name('api.clients.update');
|
|
Route::delete('clients/{client}', [ClientsController::class, 'destroy'])
|
|
->middleware('token-can:delete_clients')->name('api.clients.destroy');
|
|
// `edit_clients`, not `delete_clients`: this changes how an account
|
|
// signs in, it does not remove one — the same key its web twin uses.
|
|
Route::delete('clients/{client}/two-factor', [ClientsController::class, 'destroyTwoFactor'])
|
|
->middleware('token-can:edit_clients')->name('api.clients.two-factor.destroy');
|
|
|
|
/*
|
|
|----------------------------------------------------------------------
|
|
| Staff accounts and the roles assigned to them
|
|
|----------------------------------------------------------------------
|
|
|
|
|
| Community only. Managed installations create staff accounts outside
|
|
| the application, so an API able to mint them there would be a second,
|
|
| unmanaged door into the same thing — `capability:users.manage` is the
|
|
| same gate routes/web.php puts on the /users screens.
|
|
|
|
|
| Registered in every edition even so, and refused by the middleware
|
|
| rather than by not existing: the OpenAPI document is committed,
|
|
| served unauthenticated, and must be identical on every install
|
|
| (OpenApiContractTest compares it against the route table). A cloud
|
|
| caller gets 403 `capability_unavailable`, which says what is wrong;
|
|
| an endpoint that silently was not there would not.
|
|
|
|
|
| Permissions mirror routes/web.php exactly: `manage_users` to reach
|
|
| the area at all, then a key per action on top of it. Two `token-can:`
|
|
| middlewares stack as AND, which is how the web group + route pairing
|
|
| reads there.
|
|
|
|
|
*/
|
|
Route::middleware(['capability:users.manage', 'token-can:manage_users'])->group(function () {
|
|
Route::get('users', [UsersController::class, 'index'])->name('api.users.index');
|
|
Route::get('roles', [RolesController::class, 'index'])->name('api.roles.index');
|
|
|
|
Route::get('users/{user}', [UsersController::class, 'show'])
|
|
->middleware('token-can:edit_users')->name('api.users.show');
|
|
Route::post('users', [UsersController::class, 'store'])
|
|
->middleware('token-can:create_users')->name('api.users.store');
|
|
Route::patch('users/{user}', [UsersController::class, 'update'])
|
|
->middleware('token-can:edit_users')->name('api.users.update');
|
|
Route::delete('users/{user}', [UsersController::class, 'destroy'])
|
|
->middleware('token-can:delete_users')->name('api.users.destroy');
|
|
// See the note on api.clients.two-factor.destroy above.
|
|
Route::delete('users/{user}/two-factor', [UsersController::class, 'destroyTwoFactor'])
|
|
->middleware('token-can:edit_users')->name('api.users.two-factor.destroy');
|
|
});
|
|
|
|
/*
|
|
|----------------------------------------------------------------------
|
|
| Groups
|
|
|----------------------------------------------------------------------
|
|
*/
|
|
Route::get('groups', [GroupsController::class, 'index'])
|
|
->middleware('token-can:manage_groups')->name('api.groups.index');
|
|
Route::get('groups/{group}', [GroupsController::class, 'show'])
|
|
->middleware('token-can:edit_groups')->name('api.groups.show');
|
|
Route::post('groups', [GroupsController::class, 'store'])
|
|
->middleware('token-can:create_groups')->name('api.groups.store');
|
|
Route::patch('groups/{group}', [GroupsController::class, 'update'])
|
|
->middleware('token-can:edit_groups')->name('api.groups.update');
|
|
Route::delete('groups/{group}', [GroupsController::class, 'destroy'])
|
|
->middleware('token-can:delete_groups')->name('api.groups.destroy');
|
|
|
|
Route::middleware('token-can:edit_groups')->group(function () {
|
|
Route::post('groups/{group}/members', [GroupMembersController::class, 'store'])
|
|
->name('api.groups.members.store');
|
|
Route::delete('groups/{group}/members/{member}', [GroupMembersController::class, 'destroy'])
|
|
->name('api.groups.members.destroy');
|
|
});
|
|
|
|
/*
|
|
|----------------------------------------------------------------------
|
|
| Module endpoints
|
|
|----------------------------------------------------------------------
|
|
|
|
|
| cloud-modules and community-modules add their own endpoints here.
|
|
| Core owns the frame: the prefix, the route-name namespace, the auth
|
|
| stack above, and the capability gate. A module supplies paths and
|
|
| controllers and nothing else.
|
|
|
|
|
| Dispatched from the route file rather than from a service provider
|
|
| because route files load inside RouteServiceProvider's booted()
|
|
| callback — every package provider has already registered its listener
|
|
| by then — and because registering here keeps module routes inside
|
|
| `route:cache` instead of being bolted on after the cache is read.
|
|
|
|
|
*/
|
|
$modules = new RegisteringApiModules;
|
|
Event::dispatch($modules);
|
|
|
|
foreach ($modules->modules() as $module) {
|
|
/** @var ApiModule $module */
|
|
Route::prefix("modules/{$module->slug}")
|
|
->name("api.modules.{$module->slug}.")
|
|
->middleware($module->capability !== null ? ["capability:{$module->capability}"] : [])
|
|
->group($module->routes);
|
|
}
|
|
});
|