From 6adfd9f3fa03438e11a543a202abfb6ccd961b8d Mon Sep 17 00:00:00 2001 From: James Collins Date: Thu, 28 Sep 2023 09:59:40 +1000 Subject: [PATCH] complete secure files --- app/Conductors/MediaConductor.php | 11 +- app/Http/Controllers/Api/MediaController.php | 91 ++++++----- app/Jobs/MediaWorkerJob.php | 15 +- app/Models/Media.php | 66 ++------ app/Models/MediaJob.php | 4 +- config/filesystems.php | 21 +-- ...ename_permission_column_in_media_table.php | 20 +-- resources/js/components/SMAttachments.vue | 150 ++++++------------ .../js/components/dialogs/SMDialogMedia.vue | 10 ++ resources/js/helpers/api.types.ts | 2 +- resources/js/helpers/string.ts | 10 ++ resources/js/router/index.js | 11 +- resources/js/views/File.vue | 132 +++++++++++++++ resources/js/views/dashboard/MediaEdit.vue | 40 ++++- resources/js/views/form.ts | 8 + 15 files changed, 354 insertions(+), 237 deletions(-) create mode 100644 resources/js/views/File.vue create mode 100644 resources/js/views/form.ts diff --git a/app/Conductors/MediaConductor.php b/app/Conductors/MediaConductor.php index 547261f..1aaefb8 100644 --- a/app/Conductors/MediaConductor.php +++ b/app/Conductors/MediaConductor.php @@ -51,8 +51,9 @@ class MediaConductor extends Conductor /** @var \App\Models\User */ $user = auth()->user(); + $fields = arrayRemoveItem($fields, 'security_data'); if ($user === null || $user->hasPermission('admin/media') === false) { - $fields = arrayRemoveItem($fields, ['security', 'storage']); + $fields = arrayRemoveItem($fields, 'storage'); } return $fields; @@ -68,13 +69,13 @@ class MediaConductor extends Conductor { $user = auth()->user(); if ($user === null) { - $builder->where('security', ''); + $builder->where('security_type', ''); } else { $builder->where(function ($query) use ($user) { - $query->where('security', '') + $query->where('security_type', '') ->orWhere(function ($subquery) use ($user) { - $subquery->where('security', 'LIKE', 'permission:%') - ->whereIn(DB::raw("SUBSTRING(security, 11)"), $user->permissions); + $subquery->where('security_type', 'permission') + ->whereIn('security_data', $user->permissions); }); }); } diff --git a/app/Http/Controllers/Api/MediaController.php b/app/Http/Controllers/Api/MediaController.php index 78fbcb8..658c2b3 100644 --- a/app/Http/Controllers/Api/MediaController.php +++ b/app/Http/Controllers/Api/MediaController.php @@ -13,6 +13,7 @@ use Illuminate\Http\Request; use Illuminate\Http\UploadedFile; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Storage; use Laravel\Sanctum\PersonalAccessToken; class MediaController extends ApiController @@ -162,15 +163,20 @@ class MediaController extends ApiController $data['storage'] = $request->get('storage', ''); } - if ($request->has('security') === true || $file !== null) { - $data['security'] = $request->get('security', ''); + if ($request->has('security_type') === true || $file !== null) { + $data['security']['type'] = $request->get('security_type', ''); + $data['security']['data'] = $request->get('security_data', ''); + + if($data['security']['type'] === '') { + $data['security']['data'] = ''; + } } if(array_key_exists('storage', $data) === true && - array_key_exists('security', $data) === true && + (array_key_exists('security', $data) === true && array_key_exists('type', $data['security']) === true) && array_key_exists('mime_type', $data) === true && $data['mime_type'] !== "") { - $error = Media::verifyStorage($data['mime_type'], $data['security'], $data['storage']); + $error = Media::verifyStorage($data['mime_type'], $data['security']['type'], $data['storage']); switch($error) { case Media::STORAGE_VALID: break; @@ -284,47 +290,49 @@ class MediaController extends ApiController */ public function download(Request $request, Media $medium) { - $respondJson = in_array('application/json', explode(',', $request->header('Accept', 'application/json'))); - $headers = []; - $path = $medium->path(); - - /* File exists */ - if (file_exists($path) === false) { - if ($respondJson === false) { - return redirect('/not-found'); - } else { - return $this->respondNotFound(); - } + + /* Check file exists */ + if(Storage::disk($medium->storage)->exists($medium->name) === true) { + return $this->respondNotFound(); } - - $updated_at = Carbon::parse(filemtime($path)); + + $updated_at = Carbon::parse(Storage::disk($medium->storage)->lastModified($medium->name)); $headerPragma = 'no-cache'; $headerCacheControl = 'max-age=0, must-revalidate'; $headerExpires = $updated_at->toRfc2822String(); - if (empty($medium->permission) === true) { - if ($request->user() === null && $request->has('token') === true) { - $accessToken = PersonalAccessToken::findToken(urldecode($request->input('token'))); + /* construct user if can */ + $user = $request->user(); + if ($user === null && $request->has('token') === true) { + $accessToken = PersonalAccessToken::findToken(urldecode($request->input('token'))); - if ( - $accessToken !== null && (config('sanctum.expiration') === null || - $accessToken->created_at->lte(now()->subMinutes(config('sanctum.expiration'))) === false) - ) { - $user = $accessToken->tokenable; - } + if ( + $accessToken !== null && (config('sanctum.expiration') === null || + $accessToken->created_at->lte(now()->subMinutes(config('sanctum.expiration'))) === false) + ) { + $user = $accessToken->tokenable; } - if ($request->user() === null || $user->hasPermission($medium->permission) === false) { - if ($respondJson === false) { - return redirect('/login?redirect=' . $request->path()); - } else { - return $this->respondForbidden(); - } - } - } else { + } + + if ($medium->security_type === '') { + /* no security */ $headerPragma = 'public'; $headerExpires = $updated_at->addMonth()->toRfc2822String(); + } else if (strcasecmp('password', $medium->security_type) === 0) { + /* password */ + if( + ($user === null || $user->hasPermission('admin/media') === false) && + ($request->has('password') === false || $request->get('password') !== $medium->security_data)) { + return $this->respondForbidden(); + } + } else if (strcasecmp('permission', $medium->security_type) === 0) { + /* permission */ + if( + $user === null || ($user->hasPermission('admin/media') === false && $user->hasPermission($medium->security_data) === false)) { + return $this->respondForbidden(); + } }//end if // deepcode ignore InsecureHash: Browsers expect Etag to be a md5 hash @@ -333,7 +341,7 @@ class MediaController extends ApiController $headers = [ 'Cache-Control' => $headerCacheControl, - 'Content-Disposition' => sprintf('inline; filename="%s"', basename($path)), + 'Content-Disposition' => sprintf('inline; filename="%s"', basename($medium->name)), 'Etag' => $headerEtag, 'Expires' => $headerExpires, 'Last-Modified' => $headerLastModified, @@ -352,7 +360,18 @@ class MediaController extends ApiController return response()->make('', 304, $headers); } - return response()->file($path, $headers); + $headers['Content-Type'] = Storage::disk($medium->storage)->mimeType($medium->name); + $headers['Content-Length'] = Storage::disk($medium->storage)->size($medium->name); + $headers['Content-Disposition'] = 'inline; filename="' . basename($medium->name) . '"'; + + $stream = Storage::disk($medium->storage)->readStream($medium->name); + return response()->stream( + function () use ($stream) { + fclose($stream); + }, + 200, + $headers + ); } /** diff --git a/app/Jobs/MediaWorkerJob.php b/app/Jobs/MediaWorkerJob.php index f4a583b..af5d150 100644 --- a/app/Jobs/MediaWorkerJob.php +++ b/app/Jobs/MediaWorkerJob.php @@ -86,13 +86,14 @@ class MediaWorkerJob implements ShouldQueue }//end if // get security - $security = ''; + $security = []; if ($media === null) { if (array_key_exists('security', $data) === true) { $security = $data['security']; } } else { - $security = $media->security; + $security['type'] = $media->security_type; + $security['data'] = $media->security_data; } // get storage @@ -106,7 +107,7 @@ class MediaWorkerJob implements ShouldQueue } if ($storage === '') { - if(strlen($security) === 0) { + if(count($security) === 0 || $security['type'] === '') { if (strpos($data['mime_type'], 'image/') === 0) { $storage = 'local'; } else { @@ -145,7 +146,8 @@ class MediaWorkerJob implements ShouldQueue 'name' => $data['name'], 'mime_type' => $data['mime_type'], 'size' => $data['size'], - 'security' => $data['security'], + 'security_type' => $data['security']['type'], + 'security_data' => $data['security']['data'], 'storage' => $storage, ]); }//end if @@ -296,8 +298,9 @@ class MediaWorkerJob implements ShouldQueue } // Relocate file (if requested) - if (array_key_exists('security', $data) === true) { - $media->security = $data['security']; + if (array_key_exists('security', $data) === true && array_key_exists('type', $data['security']) === true) { + $media->security_type = $data['security']['type']; + $media->security_data = $data['security']['data']; } if (array_key_exists('storage', $data) === true) { diff --git a/app/Models/Media.php b/app/Models/Media.php index 091731e..94ee035 100644 --- a/app/Models/Media.php +++ b/app/Models/Media.php @@ -51,7 +51,8 @@ class Media extends Model 'title', 'user_id', 'mime_type', - 'security', + 'security_type', + 'security_data', 'storage', 'description', 'name', @@ -77,7 +78,8 @@ class Media extends Model 'variants' => '[]', 'description' => '', 'dimensions' => '', - 'security' => '', + 'security_type' => '', + 'security_data' => '', 'thumbnail' => '', ]; @@ -335,11 +337,12 @@ class Media extends Model */ public function getUrlAttribute(): string { - if (isset($this->attributes['name']) === true) { - return self::getUrlPath() . $this->name; - } + $url = self::getUrlPath(); - return ''; + $url = str_replace('{id}', $this->id, $url); + $url = str_replace('{name}', $this->name, $url); + + return $url; } /** @@ -377,49 +380,6 @@ class Media extends Model return null; } - /** - * Download the file from the storage to the user. - * - * @param string $variant The variant to download or null if none. - * @param boolean $fallback Fallback to the original file if the variant is not found. - * @return JsonResponse|StreamedResponse The response. - * @throws BindingResolutionException The Exception. - */ - public function download(string $variant = null, bool $fallback = true) - { - $path = $this->name; - if ($variant !== null) { - if (array_key_exists($variant, $this->variant) === true) { - $path = $this->variant[$variant]; - } else { - return response()->json( - ['message' => 'The resource was not found.'], - HttpResponseCodes::HTTP_NOT_FOUND - ); - } - } - - $disk = Storage::disk($this->storage); - if ($disk->exists($path) === true) { - $stream = $disk->readStream($path); - $response = response()->stream( - function () use ($stream) { - fpassthru($stream); - }, - 200, - [ - 'Content-Type' => $this->mime_type, - 'Content-Length' => $disk->size($path), - 'Content-Disposition' => 'attachment; filename="' . basename($path) . '"', - ] - ); - - return $response; - } - - return response()->json(['message' => 'The resource was not found.'], HttpResponseCodes::HTTP_NOT_FOUND); - } - /** * Get the server maximum upload size * @@ -773,7 +733,7 @@ class Media extends Model $newFilename = pathinfo($this->name, PATHINFO_FILENAME) . "-" . uniqid() . "-thumb.webp"; $success = false; - if ($this->security === '') { + if ($this->security_type === '') { if (strpos($this->mime_type, 'image/') === 0) { $image = Image::make($filePath); $image->orientate(); @@ -897,7 +857,7 @@ class Media extends Model } $this->variants = []; - if ($this->security === '') { + if ($this->security_type === '') { if (strpos($this->mime_type, 'image/') === 0) { // Generate additional image sizes $sizes = Media::getObjectVariants('image'); @@ -1025,13 +985,13 @@ class Media extends Model return $this->hasMany(MediaJob::class, 'media_id'); } - public static function verifyStorage($mime_type, $security, &$storage): int { + public static function verifyStorage($mime_type, $security_type, &$storage): int { if($mime_type === '') { return Media::STORAGE_MIME_MISSING; } if($storage === '') { - if($security === '') { + if($security_type === '') { if (strpos($mime_type, 'image/') === 0) { $storage = 'local'; } else { diff --git a/app/Models/MediaJob.php b/app/Models/MediaJob.php index cbecf3b..8edd02d 100644 --- a/app/Models/MediaJob.php +++ b/app/Models/MediaJob.php @@ -181,10 +181,10 @@ class MediaJob extends Model $data['mime_type'] = $mime; if(array_key_exists('storage', $data) === true && - array_key_exists('security', $data) === true && + array_key_exists('security_type', $data) === true && array_key_exists('mime_type', $data) === true && $data['mime_type'] !== "") { - $error = Media::verifyStorage($data['mime_type'], $data['security'], $data['storage']); + $error = Media::verifyStorage($data['mime_type'], $data['security_type'], $data['storage']); switch($error) { case Media::STORAGE_VALID: break; diff --git a/config/filesystems.php b/config/filesystems.php index b0764c3..22ab4b3 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -44,7 +44,7 @@ return [ 'secret' => env('AWS_PUBLIC_SECRET_ACCESS_KEY'), 'region' => env('AWS_PUBLIC_DEFAULT_REGION'), 'bucket' => env('AWS_PUBLIC_BUCKET'), - 'url' => env('AWS_PUBLIC_URL'), + 'url' => env('AWS_PUBLIC_URL') . '/{name}', 'endpoint' => env('AWS_PUBLIC_ENDPOINT'), 'use_path_style_endpoint' => env('AWS_PUBLIC_USE_PATH_STYLE_ENDPOINT', false), 'throw' => false, @@ -55,25 +55,18 @@ return [ ], 'private' => [ - 'driver' => 's3', - 'key' => env('AWS_PRIVATE_ACCESS_KEY_ID'), - 'secret' => env('AWS_PRIVATE_SECRET_ACCESS_KEY'), - 'region' => env('AWS_PRIVATE_DEFAULT_REGION'), - 'bucket' => env('AWS_PRIVATE_BUCKET'), - 'url' => env('AWS_PRIVATE_URL'), - 'endpoint' => env('AWS_PRIVATE_ENDPOINT'), - 'use_path_style_endpoint' => env('AWS_PRIVATE_USE_PATH_STYLE_ENDPOINT', false), + 'driver' => 'local', + 'root' => storage_path('app/private'), + // 'url' => env('APP_URL') . '/file', + 'url' => env('APP_URL_API') . '/media/{id}/download', + 'visibility' => 'private', 'throw' => false, - 'public' => false, - 'options' => [ - 'ACL' => '', - ] ], 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), - 'url' => env('APP_URL') . '/storage', + 'url' => env('APP_URL') . '/storage/{name}', 'visibility' => 'public', 'throw' => false, ], diff --git a/database/migrations/2023_09_25_053320_rename_permission_column_in_media_table.php b/database/migrations/2023_09_25_053320_rename_permission_column_in_media_table.php index dff2ecf..f18e265 100644 --- a/database/migrations/2023_09_25_053320_rename_permission_column_in_media_table.php +++ b/database/migrations/2023_09_25_053320_rename_permission_column_in_media_table.php @@ -12,12 +12,13 @@ return new class extends Migration public function up(): void { Schema::table('media', function (Blueprint $table) { - $table->renameColumn('permission', 'security'); + $table->string('security_type'); + $table->renameColumn('permission', 'security_data'); }); DB::table('media') - ->where('security', '!=', '') - ->update(['security' => DB::raw("CONCAT('permission:', security)")]); + ->where('security_data', '!=', '') + ->update(['security_type' => 'permission']); } /** @@ -26,17 +27,12 @@ return new class extends Migration public function down(): void { DB::table('media') - ->where(function ($query) { - $query->where('security', 'NOT LIKE', 'permission:%'); - }) - ->update(['security' => '']); - - DB::table('media') - ->where('security', 'LIKE', 'permission:%') - ->update(['security' => DB::raw("SUBSTRING(security, 11)")]); + ->where('security_type', '!=', 'permission') + ->update(['security_data' => '']); Schema::table('media', function (Blueprint $table) { - $table->renameColumn('security', 'permission'); + $table->renameColumn('security_data', 'permission'); + $table->dropColumn('security_type'); }); } }; diff --git a/resources/js/components/SMAttachments.vue b/resources/js/components/SMAttachments.vue index bd8b144..1e1204f 100644 --- a/resources/js/components/SMAttachments.vue +++ b/resources/js/components/SMAttachments.vue @@ -11,7 +11,7 @@ v-if="modelValue && modelValue.length > 0" class="w-full border-1 rounded-2 bg-white text-sm mt-2"> - + { if (result) { const mediaResult = result as Media[]; let newValue = props.modelValue; - let mediaIds = new Set(newValue.map((item) => item.id)); + let mediaIds = new Set(newValue.map((item) => (item as Media).id)); mediaResult.forEach((item) => { if (!mediaIds.has(item.id)) { @@ -128,105 +133,50 @@ const handleClickAdd = async () => { const handleClickDelete = (id: string) => { if (props.showEditor == true) { - const newList = props.modelValue.filter((item) => item.id !== id); + const newList = props.modelValue.filter( + (item) => (item as Media).id !== id, + ); emits("update:modelValue", newList); } }; + +watch( + () => props.modelValue, + (newValue) => { + updateFileList(newValue as Array); + }, +); + +onMounted(() => { + if (props.modelValue !== undefined) { + updateFileList(props.modelValue as Array); + } +}); + +const updateFileList = (newFileList: Array) => { + fileList.value = []; + + for (const mediaItem of newFileList) { + const webUrl = (import.meta as ImportMetaExtras).env.APP_URL; + const apiUrl = (import.meta as ImportMetaExtras).env.APP_URL_API; + + // Is the URL a API request? + if (mediaItem.url.startsWith(apiUrl)) { + const fileUrlPath = mediaItem.url.substring(apiUrl.length); + const fileUrlParts = fileUrlPath.split("/"); + + if ( + fileUrlParts.length === 4 && + fileUrlParts[0].length === 0 && + strCaseCmp("media", fileUrlParts[1]) === true && + strCaseCmp("download", fileUrlParts[3]) === true + ) { + mediaItem.url = webUrl + "/file/" + fileUrlParts[2]; + fileList.value.push(mediaItem); + } + } else { + fileList.value.push(mediaItem); + } + } +}; - - diff --git a/resources/js/components/dialogs/SMDialogMedia.vue b/resources/js/components/dialogs/SMDialogMedia.vue index d2bdce2..5b2b66b 100644 --- a/resources/js/components/dialogs/SMDialogMedia.vue +++ b/resources/js/components/dialogs/SMDialogMedia.vue @@ -144,6 +144,16 @@ item, )}')`, }"> +
+ + locked + + +
{{ item.title }} diff --git a/resources/js/helpers/api.types.ts b/resources/js/helpers/api.types.ts index f341e92..c8c110c 100644 --- a/resources/js/helpers/api.types.ts +++ b/resources/js/helpers/api.types.ts @@ -66,7 +66,7 @@ export interface Media { title: string; name: string; mime_type: string; - permission: string; + security_type: string; size: number; storage: string; url: string; diff --git a/resources/js/helpers/string.ts b/resources/js/helpers/string.ts index d37d1db..f6ae44a 100644 --- a/resources/js/helpers/string.ts +++ b/resources/js/helpers/string.ts @@ -113,3 +113,13 @@ export const toPrice = (numOrString: number | string): string => { : numOrString; return num.toFixed(num % 1 === 0 ? 0 : 2); }; + +/** + * Compare 2 strings case insensitive + * @param {string} string1 The first string for comparison. + * @param {string} string2 The second string for comparison. + * @returns {boolean} If the strings match. + */ +export const strCaseCmp = (string1: string, string2: string): boolean => { + return string1.toLowerCase() === string2.toLowerCase(); +}; diff --git a/resources/js/router/index.js b/resources/js/router/index.js index d5d4a3b..19a54f3 100644 --- a/resources/js/router/index.js +++ b/resources/js/router/index.js @@ -426,6 +426,14 @@ export const routes = [ }, component: () => import("@/views/ForgotPassword.vue"), }, + { + path: "/file/:id", + name: "file", + meta: { + title: "File", + }, + component: () => import("@/views/File.vue"), + }, { path: "/cart", name: "cart", @@ -448,8 +456,7 @@ export const routes = [ const router = createRouter({ history: createWebHistory(), routes, - scrollBehavior(to, from, savedPosition) { - // always scroll to top + scrollBehavior() { return { top: 0 }; }, }); diff --git a/resources/js/views/File.vue b/resources/js/views/File.vue new file mode 100644 index 0000000..1c9d121 --- /dev/null +++ b/resources/js/views/File.vue @@ -0,0 +1,132 @@ + + + diff --git a/resources/js/views/dashboard/MediaEdit.vue b/resources/js/views/dashboard/MediaEdit.vue index ea9cfed..2119634 100644 --- a/resources/js/views/dashboard/MediaEdit.vue +++ b/resources/js/views/dashboard/MediaEdit.vue @@ -21,7 +21,26 @@ accepts="*" class="mb-4" /> - +
+ + +
@@ -77,7 +96,7 @@