Changeset View
Changeset View
Standalone View
Standalone View
src/app/Http/Controllers/API/V4/FilesController.php
- This file was added.
<?php | |||||
namespace App\Http\Controllers\API\V4; | |||||
use App\Backends\Storage; | |||||
use App\Fs\File; | |||||
use App\Fs\FilePermission; | |||||
use App\Http\Controllers\RelationController; | |||||
use App\Rules\FileName; | |||||
use App\User; | |||||
use App\Utils; | |||||
use Illuminate\Http\Request; | |||||
use Illuminate\Support\Facades\Cache; | |||||
use Illuminate\Support\Facades\Validator; | |||||
class FilesController extends RelationController | |||||
{ | |||||
/** @var string Resource localization label */ | |||||
protected $label = 'file'; | |||||
/** @var string Resource model name */ | |||||
protected $model = File::class; | |||||
/** @var array Common object properties in the API response */ | |||||
protected $objectProps = ['mimetype', 'name', 'size']; | |||||
/** | |||||
* Delete a file. | |||||
* | |||||
* @param string $id File identifier | |||||
* | |||||
* @return \Illuminate\Http\JsonResponse The response | |||||
*/ | |||||
public function destroy($id) | |||||
{ | |||||
$file = $this->inputFile($id, FilePermission::DELETE); | |||||
if (is_int($file)) { | |||||
return $this->errorResponse($file); | |||||
} | |||||
// FIXME: Here we're just deleting the file, but maybe it would be better/faster | |||||
// to mark the file (record in db) as deleted and invoke a job to | |||||
// delete it asynchronously? | |||||
Storage::fileDelete($file); | |||||
$file->delete(); | |||||
return response()->json([ | |||||
'status' => 'success', | |||||
'message' => \trans('app.file-delete-success'), | |||||
]); | |||||
} | |||||
/** | |||||
* Fetch content of a file. | |||||
* | |||||
* @param string $id The download (not file) identifier. | |||||
* | |||||
* @return \Illuminate\Http\Response|\Symfony\Component\HttpFoundation\StreamedResponse | |||||
*/ | |||||
public function download($id) | |||||
{ | |||||
$fileId = Cache::get('download:' . $id); | |||||
if (!$fileId) { | |||||
return response('Not found', 404); | |||||
} | |||||
$file = File::find($fileId); | |||||
if (!$file) { | |||||
return response('Not found', 404); | |||||
} | |||||
return Storage::fileDownload($file); | |||||
} | |||||
/** | |||||
* Fetch the permissions for the specific file. | |||||
* | |||||
* @param string $fileId The file identifier. | |||||
* | |||||
* @return \Illuminate\Http\JsonResponse | |||||
*/ | |||||
public function getPermissions($fileId) | |||||
{ | |||||
// Only the file owner can do that, for now | |||||
$file = $this->inputFile($fileId, null); | |||||
if (is_int($file)) { | |||||
return $this->errorResponse($file); | |||||
} | |||||
$result = $file->permissions()->orderBy('user')->get()->map( | |||||
fn($permission) => self::permissionToClient($permission) | |||||
); | |||||
$result = [ | |||||
'list' => $result, | |||||
'count' => count($result), | |||||
]; | |||||
return response()->json($result); | |||||
} | |||||
/** | |||||
* Add permission for the specific file. | |||||
* | |||||
* @param string $fileId The file identifier. | |||||
* | |||||
* @return \Illuminate\Http\JsonResponse | |||||
*/ | |||||
public function createPermission($fileId) | |||||
{ | |||||
// Only the file owner can do that, for now | |||||
$file = $this->inputFile($fileId, null); | |||||
if (is_int($file)) { | |||||
return $this->errorResponse($file); | |||||
} | |||||
// Validate/format input | |||||
$v = Validator::make(request()->all(), [ | |||||
'user' => 'email|required', | |||||
'permissions' => 'string|required', | |||||
]); | |||||
$errors = $v->fails() ? $v->errors()->toArray() : []; | |||||
$acl = self::inputAcl(request()->input('permissions')); | |||||
if (empty($errors['permissions']) && empty($acl)) { | |||||
$errors['permissions'] = \trans('validation.file-perm-invalid'); | |||||
} | |||||
$user = \strtolower(request()->input('user')); | |||||
if (empty($errors['user']) && $file->permissions()->where('user', $user)->exists()) { | |||||
$errors['user'] = \trans('validation.file-perm-exists'); | |||||
} | |||||
if (!empty($errors)) { | |||||
return response()->json(['status' => 'error', 'errors' => $errors], 422); | |||||
} | |||||
// Create the permission | |||||
$permission = $file->permissions()->create([ | |||||
'user' => $user, | |||||
'permissions' => $acl, | |||||
]); | |||||
$result = self::permissionToClient($permission); | |||||
return response()->json($result + [ | |||||
'status' => 'success', | |||||
'message' => \trans('app.file-permissions-create-success'), | |||||
]); | |||||
} | |||||
/** | |||||
* Delete file permission. | |||||
* | |||||
* @param string $fileId The file identifier. | |||||
* @param string $id The file permission identifier. | |||||
* | |||||
* @return \Illuminate\Http\JsonResponse | |||||
*/ | |||||
public function deletePermission($fileId, $id) | |||||
{ | |||||
// Only the file owner can do that, for now | |||||
$file = $this->inputFile($fileId, null); | |||||
if (is_int($file)) { | |||||
return $this->errorResponse($file); | |||||
} | |||||
$permission = $file->permissions()->find($id); | |||||
if (!$permission) { | |||||
return $this->errorResponse(404); | |||||
} | |||||
$permission->delete(); | |||||
return response()->json([ | |||||
'status' => 'success', | |||||
'message' => \trans('app.file-permissions-delete-success'), | |||||
]); | |||||
} | |||||
/** | |||||
* Update file permission. | |||||
* | |||||
* @param \Illuminate\Http\Request $request The API request. | |||||
* @param string $fileId The file identifier. | |||||
* @param string $id The file permission identifier. | |||||
* | |||||
* @return \Illuminate\Http\JsonResponse | |||||
*/ | |||||
public function updatePermission(Request $request, $fileId, $id) | |||||
{ | |||||
// Only the file owner can do that, for now | |||||
$file = $this->inputFile($fileId, null); | |||||
if (is_int($file)) { | |||||
return $this->errorResponse($file); | |||||
} | |||||
$permission = $file->permissions()->find($id); | |||||
if (!$permission) { | |||||
return $this->errorResponse(404); | |||||
} | |||||
// Validate/format input | |||||
$v = Validator::make($request->all(), [ | |||||
'user' => 'email|required', | |||||
'permissions' => 'string|required', | |||||
]); | |||||
$errors = $v->fails() ? $v->errors()->toArray() : []; | |||||
$acl = self::inputAcl($request->input('permissions')); | |||||
if (empty($errors['permissions']) && empty($acl)) { | |||||
$errors['permissions'] = \trans('validation.file-perm-invalid'); | |||||
} | |||||
$user = \strtolower($request->input('user')); | |||||
if (empty($errors['user']) && $user != $permission->user | |||||
&& $file->permissions()->where('user', $user)->exists() | |||||
) { | |||||
$errors['user'] = \trans('validation.file-perm-exists'); | |||||
} | |||||
if (!empty($errors)) { | |||||
return response()->json(['status' => 'error', 'errors' => $errors], 422); | |||||
} | |||||
$permission->user = $user; | |||||
$permission->permissions = $acl; | |||||
$permission->save(); | |||||
$result = self::permissionToClient($permission); | |||||
return response()->json($result + [ | |||||
'status' => 'success', | |||||
'message' => \trans('app.file-permissions-update-success'), | |||||
]); | |||||
} | |||||
/** | |||||
* Listing of files (and folders). | |||||
* | |||||
* @return \Illuminate\Http\JsonResponse | |||||
*/ | |||||
public function index() | |||||
{ | |||||
$search = trim(request()->input('search')); | |||||
$page = intval(request()->input('page')) ?: 1; | |||||
$pageSize = 100; | |||||
$hasMore = false; | |||||
$user = $this->guard()->user(); | |||||
$result = $user->files()->where('mimetype', '<>', File::TYPE_INCOMPLETE); | |||||
if (strlen($search)) { | |||||
$result->whereLike('name', $search); | |||||
} | |||||
$result = $result->orderBy('name') | |||||
->limit($pageSize + 1) | |||||
->offset($pageSize * ($page - 1)) | |||||
->get(); | |||||
if (count($result) > $pageSize) { | |||||
$result->pop(); | |||||
$hasMore = true; | |||||
} | |||||
// Process the result | |||||
$result = $result->map( | |||||
function ($file) { | |||||
$result = $this->objectToClient($file); | |||||
$result['mtime'] = $file->updated_at->format('Y-m-d H:i'); | |||||
return $result; | |||||
} | |||||
); | |||||
$result = [ | |||||
'list' => $result, | |||||
'count' => count($result), | |||||
'hasMore' => $hasMore, | |||||
]; | |||||
return response()->json($result); | |||||
} | |||||
/** | |||||
* Fetch the specific file metadata or content. | |||||
* | |||||
* @param string $id The file identifier. | |||||
* | |||||
* @return \Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\StreamedResponse | |||||
*/ | |||||
public function show($id) | |||||
{ | |||||
$file = $this->inputFile($id, FilePermission::READ); | |||||
if (is_int($file)) { | |||||
return $this->errorResponse($file); | |||||
} | |||||
$response = $this->objectToClient($file); | |||||
if (request()->input('downloadUrl')) { | |||||
// Generate a download URL (that does not require authentication) | |||||
$downloadId = Utils::uuidStr(); | |||||
Cache::add('download:' . $downloadId, $file->id, 60); | |||||
$response['downloadUrl'] = Utils::serviceUrl('api/v4/files/downloads/' . $downloadId); | |||||
} elseif (request()->input('download')) { | |||||
// Return the file content | |||||
return Storage::fileDownload($file); | |||||
} | |||||
$response['mtime'] = $file->updated_at->format('Y-m-d H:i'); | |||||
// TODO: Handle read-write/full access rights | |||||
$isOwner = $this->guard()->user()->id == $file->user_id; | |||||
$response['canUpdate'] = $isOwner; | |||||
$response['canDelete'] = $isOwner; | |||||
$response['isOwner'] = $isOwner; | |||||
return response()->json($response); | |||||
} | |||||
/** | |||||
* Create a new file. | |||||
* | |||||
* @param \Illuminate\Http\Request $request The API request. | |||||
* | |||||
* @return \Illuminate\Http\JsonResponse The response | |||||
*/ | |||||
public function store(Request $request) | |||||
{ | |||||
$user = $this->guard()->user(); | |||||
// Validate file name input | |||||
$v = Validator::make($request->all(), ['name' => ['required', new FileName($user)]]); | |||||
if ($v->fails()) { | |||||
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); | |||||
} | |||||
$filename = $request->input('name'); | |||||
$media = $request->input('media'); | |||||
// FIXME: Normally people just drag and drop/upload files. | |||||
// The client side will not know whether the file with the same name | |||||
// already exists or not. So, in such a case should we throw | |||||
// an error or accept the request as an update? | |||||
$params = []; | |||||
if ($media == 'resumable') { | |||||
$params['uploadId'] = 'resumable'; | |||||
$params['size'] = $request->input('size'); | |||||
$params['from'] = $request->input('from') ?: 0; | |||||
} | |||||
// TODO: Delete the existing incomplete file with the same name? | |||||
$file = $user->files()->create([ | |||||
'mimetype' => File::TYPE_INCOMPLETE, | |||||
'name' => $filename, | |||||
]); | |||||
try { | |||||
$response = Storage::fileInput($request->getContent(true), $params, $file); | |||||
$response['status'] = 'success'; | |||||
if (!empty($response['id'])) { | |||||
$response['message'] = \trans('app.file-create-success'); | |||||
} | |||||
} catch (\Exception $e) { | |||||
\Log::error($e); | |||||
$file->delete(); | |||||
return $this->errorResponse(500); | |||||
} | |||||
return response()->json($response); | |||||
} | |||||
/** | |||||
* Update a file. | |||||
* | |||||
* @param \Illuminate\Http\Request $request The API request. | |||||
* @param string $id File identifier | |||||
* | |||||
* @return \Illuminate\Http\JsonResponse The response | |||||
*/ | |||||
public function update(Request $request, $id) | |||||
{ | |||||
$file = $this->inputFile($id, FilePermission::WRITE); | |||||
if (is_int($file)) { | |||||
return $this->errorResponse($file); | |||||
} | |||||
$media = $request->input('media') ?: 'metadata'; | |||||
if ($media == 'metadata') { | |||||
$filename = $request->input('name'); | |||||
// Validate file name input | |||||
if ($filename != $file->name) { | |||||
echo ":$filename\n"; | |||||
$v = Validator::make($request->all(), ['name' => [new FileName($file->user)]]); | |||||
if ($v->fails()) { | |||||
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); | |||||
} | |||||
$file->name = $filename; | |||||
} | |||||
$file->save(); | |||||
} elseif ($media == 'resumable' || $media == 'content') { | |||||
$params = []; | |||||
if ($media == 'resumable') { | |||||
$params['uploadId'] = 'resumable'; | |||||
$params['size'] = $request->input('size'); | |||||
$params['from'] = $request->input('from') ?: 0; | |||||
} | |||||
try { | |||||
$response = Storage::fileInput($request->getContent(true), $params, $file); | |||||
} catch (\Exception $e) { | |||||
\Log::error($e); | |||||
return $this->errorResponse(500); | |||||
} | |||||
} else { | |||||
$errors = ['media' => \trans('validation.entryinvalid', ['attribute' => 'media'])]; | |||||
return response()->json(['status' => 'error', 'errors' => $errors], 422); | |||||
} | |||||
$response['status'] = 'success'; | |||||
if ($media == 'metadata' || !empty($response['id'])) { | |||||
$response['message'] = \trans('app.file-update-success'); | |||||
} | |||||
return response()->json($response); | |||||
} | |||||
/** | |||||
* Upload a file content. | |||||
* | |||||
* @param \Illuminate\Http\Request $request The API request. | |||||
* @param string $id Upload (not file) identifier | |||||
* | |||||
* @return \Illuminate\Http\JsonResponse The response | |||||
*/ | |||||
public function upload(Request $request, $id) | |||||
{ | |||||
$params = [ | |||||
'uploadId' => $id, | |||||
'from' => $request->input('from') ?: 0, | |||||
]; | |||||
try { | |||||
$response = Storage::fileInput($request->getContent(true), $params); | |||||
$response['status'] = 'success'; | |||||
if (!empty($response['id'])) { | |||||
$response['message'] = \trans('app.file-upload-success'); | |||||
} | |||||
} catch (\Exception $e) { | |||||
\Log::error($e); | |||||
return $this->errorResponse(500); | |||||
} | |||||
return response()->json($response); | |||||
} | |||||
/** | |||||
* Convert FilePermission to an array for the API response. | |||||
* | |||||
* @param \App\Fs\FilePermission $permission File permission record | |||||
* | |||||
* @return array Permission data | |||||
*/ | |||||
protected static function permissionToClient(FilePermission $permission): array | |||||
{ | |||||
// FIXME: Here we map internal format to the one supported | |||||
// by the ACL widget, but I guess we can get rid of this limitation | |||||
// in the future, if needed. | |||||
if ($permission->permissions & FilePermission::DELETE) { | |||||
$perms = 'full'; | |||||
} elseif ($permission->permissions & FilePermission::WRITE) { | |||||
$perms = 'read-write'; | |||||
} | |||||
return [ | |||||
'id' => $permission->id, | |||||
'user' => $permission->user, | |||||
'permissions' => $perms ?? 'read-only', | |||||
'link' => Utils::serviceUrl('file/share-' . $permission->id), | |||||
]; | |||||
} | |||||
/** | |||||
* Convert ACL label into internal permissions spec. | |||||
* | |||||
* @param string $acl Access rights label | |||||
* | |||||
* @return ?int Permissions | |||||
*/ | |||||
protected static function inputAcl($acl): ?int | |||||
{ | |||||
// The ACL widget supports 'full', 'read-write', 'read-only', | |||||
if ($acl == 'full') { | |||||
return FilePermission::DELETE | FilePermission::WRITE | FilePermission::READ; | |||||
} | |||||
if ($acl == 'read-write') { | |||||
return FilePermission::WRITE | FilePermission::READ; | |||||
} | |||||
if ($acl == 'read-only') { | |||||
return FilePermission::READ; | |||||
} | |||||
return null; | |||||
} | |||||
/** | |||||
* Get the input file object, check permissions | |||||
* | |||||
* @param string $fileId File or file permission identifier | |||||
* @param ?int $permission Required access rights | |||||
* | |||||
* @return \App\Fs\File|int File object or error code | |||||
*/ | |||||
protected function inputFile($fileId, $permission) | |||||
{ | |||||
$user = $this->guard()->user(); | |||||
// Access via file permission identifier | |||||
if (str_starts_with($fileId, 'share-')) { | |||||
$fp = FilePermission::find(substr($fileId, 6)); | |||||
if (!$fp) { | |||||
return 404; | |||||
} | |||||
if (!$permission || $fp->user != $user->email || !($fp->permissions & $permission)) { | |||||
return 403; | |||||
} | |||||
$fileId = $fp->file_id; | |||||
} | |||||
$file = File::find($fileId); | |||||
if (!$file) { | |||||
return 404; | |||||
} | |||||
// For direct file access check if the user is the file owner | |||||
if (empty($fp) && $user->id != $file->user_id) { | |||||
return 403; | |||||
} | |||||
return $file; | |||||
} | |||||
} |