From 4a5fe4b1dd6a8943df59b71600eabc74aeda6f3a Mon Sep 17 00:00:00 2001 From: Justintime50 <39606064+Justintime50@users.noreply.github.com> Date: Thu, 21 Sep 2023 00:30:18 -0600 Subject: [PATCH] feat: image references stored to database --- CHANGELOG.md | 1 + src/app/Http/Controllers/ImageController.php | 117 ++++++++++++++++++ src/app/Http/Controllers/PostController.php | 101 +++------------ src/app/Http/Controllers/UserController.php | 28 ++++- src/app/Models/Image.php | 16 +++ src/app/Models/Post.php | 5 + src/app/Models/User.php | 5 + src/database/factories/PostFactory.php | 1 - ...023_09_20_225423_overhaul_photos_table.php | 104 ++++++++++++++++ src/resources/js/glass.js | 4 +- src/resources/views/admin.blade.php | 8 +- src/resources/views/comments.blade.php | 9 +- src/resources/views/create-post.blade.php | 7 +- src/resources/views/edit-post.blade.php | 16 +-- .../views/partials/image-gallery.blade.php | 17 +-- src/resources/views/post.blade.php | 27 ++-- src/resources/views/posts.blade.php | 10 +- src/resources/views/profile.blade.php | 10 +- src/routes/web.php | 6 +- src/tests/Unit/ImageControllerTest.php | 37 ++++++ src/tests/Unit/PostControllerTest.php | 28 ----- 21 files changed, 389 insertions(+), 168 deletions(-) create mode 100644 src/app/Http/Controllers/ImageController.php create mode 100644 src/app/Models/Image.php create mode 100644 src/database/migrations/2023_09_20_225423_overhaul_photos_table.php create mode 100644 src/tests/Unit/ImageControllerTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 526d3b3..2baf42a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Next Release +- Image references are now stored in the database instead of losely through the filesystem. This means that tracking what images belong to what users and posts should work much smoother. Additionally, various improvements were made across the app to how images are handled resulting in a vastly improved image experience - Categories are now unique and required, enforced at the database level - Various database indexes were added for common lookup patterns for quick retrieval - Foreign key constraints were added to enforce data integrity at the database level when records are related to one another diff --git a/src/app/Http/Controllers/ImageController.php b/src/app/Http/Controllers/ImageController.php new file mode 100644 index 0000000..0a5b51c --- /dev/null +++ b/src/app/Http/Controllers/ImageController.php @@ -0,0 +1,117 @@ +get(); + + return view('images', compact('images')); + } + + /** + * Upload an image to local storage. + * + * @param Request $request + * @return RedirectResponse + */ + public function uploadPostImage(Request $request): RedirectResponse + { + $request->validate([ + 'upload_image' => 'required|image|mimes:jpeg,jpg,png|max:2048', + ]); + + $file = $request->file('upload_image'); + $filename = ImageController::sanatizeImageFilename($file); + + ImageManagerStatic::make($file) + ->save(ImageController::getImagePublicPath(self::$postImagesSubdirectory, $filename)); + + $image = new Image(); + $image->subdirectory = self::$postImagesSubdirectory; + $image->filename = $filename; + $image->save(); + + session()->flash('message', 'Image uploaded successfully.'); + return redirect()->back(); + } + + /** + * Delete an image. + * + * @param Request $request + * @param int $id + * @return RedirectResponse + */ + public function deletePostImage(Request $request, int $id): RedirectResponse + { + $image = Image::find($id); + unlink(ImageController::getImagePublicPath($image->subdirectory, $image->filename)); + $image->delete(); + + session()->flash('message', 'Image deleted.'); + return redirect()->back(); + } + + /** + * Sanatizes an image filename. + * + * @param \Illuminate\Http\UploadedFile|\Illuminate\Http\UploadedFile[]|array|null $file + * @return string + */ + public static function sanatizeImageFilename($file): string + { + $filename = preg_replace('/[^A-Za-z0-9\-\_]/', '', pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME)); + $fileExtension = pathinfo($file->getClientOriginalName(), PATHINFO_EXTENSION); + $newFilename = $filename . '-' . time() . '.' . $fileExtension; + + return $newFilename; + } + + /** + * Gets the image asset path for a post. + * + * @param ?string $subdirectory + * @param ?string $imageName + * @return string|null + */ + public static function getImageAssetPath(?string $subdirectory, ?string $imageName): string|null + { + return isset($subdirectory) && isset($imageName) ? asset('storage/' . self::$imagesDir . "/$subdirectory/$imageName") : null; + } + + /** + * Gets the public image path for a post. + * + * @param ?string $subdirectory + * @param ?string $imageName + * @return string|null + */ + public static function getImagePublicPath(?string $subdirectory, ?string $imageName): string|null + { + return isset($subdirectory) && isset($imageName) ? public_path('storage/' . self::$imagesDir . "/$subdirectory/$imageName") : null; + } +} diff --git a/src/app/Http/Controllers/PostController.php b/src/app/Http/Controllers/PostController.php index 46cb7c4..4b5841b 100644 --- a/src/app/Http/Controllers/PostController.php +++ b/src/app/Http/Controllers/PostController.php @@ -4,6 +4,7 @@ use App\Models\Category; use App\Models\Comment; +use App\Models\Image; use App\Models\Post; use App\Models\User; use Illuminate\Contracts\View\View; @@ -11,7 +12,6 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Validation\Rule; -use Intervention\Image\ImageManagerStatic as Image; class PostController extends Controller { @@ -108,6 +108,7 @@ public function showPost(Request $request, string $user, string $slug): View ->where('published', '=', 1) ->firstOrFail(); } + $comments = Comment::where('post_id', '=', $post->id) ->orderBy('created_at', 'asc') ->paginate(10); @@ -125,7 +126,10 @@ public function showCreatePage(Request $request): View { $categories = Category::all(); - return view('create-post', compact('categories')); + $images = Image::where('subdirectory', '=', ImageController::$postImagesSubdirectory) + ->get(); + + return view('create-post', compact('categories', 'images')); } /** @@ -140,9 +144,13 @@ public function showEditPage(Request $request, string $user, string $slug): View { $post = Post::where('slug', '=', $slug) ->firstOrFail(); + $categories = Category::all(); - return view('edit-post', compact('post', 'categories')); + $images = Image::where('subdirectory', '=', ImageController::$postImagesSubdirectory) + ->get(); + + return view('edit-post', compact('post', 'categories', 'images')); } /** @@ -168,14 +176,14 @@ public function create(Request $request): RedirectResponse 'keywords' => 'nullable|string', 'category_id' => 'nullable|integer', 'post' => 'required|string', - 'banner_image_url' => 'nullable|string', + 'image_id' => 'nullable|integer', 'published' => 'required|integer', ]); $post->title = $request->input('title'); $post->slug = $request->input('slug'); $post->published = $request->input('published'); - $post->banner_image_url = $request->input('banner_image_url'); + $post->image_id = $request->input('image_id'); $post->keywords = $request->input('keywords'); $post->category_id = $request->input('category_id'); $post->post = $request->input('post'); @@ -208,12 +216,12 @@ public function update(Request $request, int $id): RedirectResponse 'keywords' => 'nullable|string', 'category_id' => 'nullable|integer', 'post' => 'required|string', - 'banner_image_url' => 'nullable|string', + 'image_id' => 'nullable|integer', 'published' => 'required|integer', ]); $post->published = $request->input('published'); - $post->banner_image_url = $request->input('banner_image_url'); + $post->image_id = $request->input('image_id'); $post->title = $request->input('title'); $post->slug = $request->input('slug'); $post->keywords = $request->input('keywords'); @@ -227,7 +235,7 @@ public function update(Request $request, int $id): RedirectResponse } /** - * Delete a post and its associated comments. + * Delete a post, its image, and its associated comments. * * @param Request $request * @param int $id @@ -237,64 +245,15 @@ public function delete(Request $request, int $id): RedirectResponse { $post = Post::find($id); $post->comments()->delete(); + $image = Image::find($post->image_id); + unlink(ImageController::getImagePublicPath($image->subdirectory, $image->filename)); + $image->delete(); $post->delete(); session()->flash('message', 'Post deleted.'); return redirect('/'); } - /** - * Show the image gallery. - * - * Images will have a unique ID associated with them which can be referenced to show the images in posts. - * - * @param Request $request - * @return View - */ - public function showImagesPage(Request $request): View - { - return view('images'); - } - - /** - * Upload an image to local storage. - * - * @param Request $request - * @return RedirectResponse - */ - public function uploadPostImage(Request $request): RedirectResponse - { - $request->validate([ - 'upload_image' => 'required|image|mimes:jpeg,jpg,png|max:2048', - ]); - - // ~1 billion possible IDs, overlap potential should be small - $idMin = 1000000000; - $idMax = 9999999999; - $id = mt_rand($idMin, $idMax); - - Image::make($request->file('upload_image'))->save(self::getImagePublicPath("$id.png")); - - session()->flash('message', 'Image uploaded successfully.'); - return redirect()->back(); - } - - /** - * Delete an image. - * - * @param Request $request - * @param int $id - * @return RedirectResponse - */ - public function deletePostImage(Request $request, int $id): RedirectResponse - { - // TODO: Store image IDs in a database - unlink(public_path("storage/images/posts/$id.png")); - - session()->flash('message', 'Image deleted.'); - return redirect()->back(); - } - /** * Generate reading time for an article. * @@ -310,26 +269,4 @@ public static function generateReadingTime(Post $post): int return $readingTime; } - - /** - * Gets the image asset path for a post. - * - * @param string $imageName - * @return string - */ - public static function getImageAssetPath(string $imageName): string - { - return asset("storage/images/posts/$imageName"); - } - - /** - * Gets the public image path for a post. - * - * @param string $imageName - * @return string - */ - public static function getImagePublicPath(string $imageName): string - { - return public_path("storage/images/posts/$imageName"); - } } diff --git a/src/app/Http/Controllers/UserController.php b/src/app/Http/Controllers/UserController.php index cb31829..af9311d 100644 --- a/src/app/Http/Controllers/UserController.php +++ b/src/app/Http/Controllers/UserController.php @@ -2,13 +2,14 @@ namespace App\Http\Controllers; +use App\Models\Image; use App\Models\User; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\View\View; -use Intervention\Image\ImageManagerStatic as Image; +use Intervention\Image\ImageManagerStatic; class UserController extends Controller { @@ -68,6 +69,10 @@ public function updatePassword(Request $request): RedirectResponse /** * Update the user profile picture. * + * 1. Store the image on disk + * 2. Make an entry of the image filename in the images table + * 3. Associate the new image to the user + * * @param Request $request * @return RedirectResponse */ @@ -76,11 +81,21 @@ public function updateProfilePic(Request $request): RedirectResponse $request->validate([ 'upload_profile_pic' => 'required|image|mimes:jpeg,jpg,png|max:2048', ]); - $id = $request->input('id'); + $file = $request->file('upload_profile_pic'); + $filename = ImageController::sanatizeImageFilename($file); - Image::make($request->file('upload_profile_pic')) + ImageManagerStatic::make($file) ->fit(150, 150) - ->save(public_path("storage/images/avatars/$id.png")); + ->save(ImageController::getImagePublicPath(ImageController::$avatarImagesSubdirectory, $filename)); + + $image = new Image(); + $image->subdirectory = ImageController::$avatarImagesSubdirectory; + $image->filename = $filename; + $image->save(); + + $user = User::find(Auth::user()->id); + $user->image_id = $image->id; + $user->save(); session()->flash('message', 'Avatar updated successfully.'); return redirect()->back(); @@ -95,7 +110,10 @@ public function updateProfilePic(Request $request): RedirectResponse */ public function delete(Request $request, int $id): RedirectResponse { - User::find($id)->delete(); + $user = User::find($id); + $image = Image::find($user->image_id); + unlink(ImageController::getImagePublicPath($image->subdirectory, $image->filename)); + $user->delete(); session()->flash('message', 'User deleted.'); return redirect()->back(); diff --git a/src/app/Models/Image.php b/src/app/Models/Image.php new file mode 100644 index 0000000..359f859 --- /dev/null +++ b/src/app/Models/Image.php @@ -0,0 +1,16 @@ +belongsTo(User::class); + } +} diff --git a/src/app/Models/Post.php b/src/app/Models/Post.php index 522b9b4..f260b34 100644 --- a/src/app/Models/Post.php +++ b/src/app/Models/Post.php @@ -35,4 +35,9 @@ public function comments() { return $this->hasMany(Comment::class); } + + public function image() + { + return $this->hasOne(Image::class, 'id', 'image_id'); + } } diff --git a/src/app/Models/User.php b/src/app/Models/User.php index 5ed9ec5..e45f2aa 100644 --- a/src/app/Models/User.php +++ b/src/app/Models/User.php @@ -49,4 +49,9 @@ class User extends Authenticatable protected $casts = [ 'email_verified_at' => 'datetime', ]; + + public function image() + { + return $this->hasOne(Image::class, 'id', 'image_id'); + } } diff --git a/src/database/factories/PostFactory.php b/src/database/factories/PostFactory.php index c905187..92793de 100644 --- a/src/database/factories/PostFactory.php +++ b/src/database/factories/PostFactory.php @@ -29,7 +29,6 @@ public function definition() 'category_id' => Category::inRandomOrder()->first()->id, 'user_id' => User::inRandomOrder()->first()->id, 'post' => $this->faker->paragraph(), - 'banner_image_url' => null, 'published' => 1, ]; } diff --git a/src/database/migrations/2023_09_20_225423_overhaul_photos_table.php b/src/database/migrations/2023_09_20_225423_overhaul_photos_table.php new file mode 100644 index 0000000..27f6d98 --- /dev/null +++ b/src/database/migrations/2023_09_20_225423_overhaul_photos_table.php @@ -0,0 +1,104 @@ +string('user_id')->change(); // change to string, rename to follow + $table->string('post_id')->change(); // change to string, rename to follow + $table->dropColumn('url'); + }); + + // Split so SQLite via test suite can work since it doesn't support renaming multiple columns at once + Schema::table('photos', function (Blueprint $table) { + $table->renameColumn('user_id', 'subdirectory'); + }); + + // Split so SQLite via test suite can work since it doesn't support renaming multiple columns at once + Schema::table('photos', function (Blueprint $table) { + $table->renameColumn('post_id', 'filename'); + }); + + Schema::rename('photos', 'images'); + + // Split so SQLite via test suite can work since it doesn't support renaming multiple columns at once + Schema::table('users', function (Blueprint $table) { + $table->renameColumn('profile_pic_id', 'image_id'); + }); + + Schema::table('users', function (Blueprint $table) { + $table->bigInteger('image_id')->unsigned()->change(); + $table->foreign('image_id')->references('id')->on('images'); + }); + + // Split so SQLite via test suite can work since it doesn't support renaming multiple columns at once + Schema::table('posts', function (Blueprint $table) { + $table->renameColumn('banner_image_url', 'image_id'); + }); + + Schema::table('posts', function (Blueprint $table) { + $table->bigInteger('image_id')->unsigned()->change(); + $table->foreign('image_id')->references('id')->on('images'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('images', function (Blueprint $table) { + $table->integer('subdirectory')->change(); + $table->integer('filename')->change(); + $table->string('url'); + }); + + // Split so SQLite via test suite can work since it doesn't support renaming multiple columns at once + Schema::table('images', function (Blueprint $table) { + $table->renameColumn('subdirectory', 'user_id'); + }); + + // Split so SQLite via test suite can work since it doesn't support renaming multiple columns at once + Schema::table('images', function (Blueprint $table) { + $table->renameColumn('filename', 'post_id'); + }); + + Schema::rename('images', 'photos'); + + Schema::table('users', function (Blueprint $table) { + $table->dropForeign('users_image_id_foreign'); + $table->dropIndex('users_image_id_foreign'); + }); + + Schema::table('users', function (Blueprint $table) { + $table->integer('image_id')->change(); + }); + + // Split so SQLite via test suite can work since it doesn't support renaming multiple columns at once + Schema::table('users', function (Blueprint $table) { + $table->renameColumn('image_id', 'profile_pic_id'); + }); + + // Split so SQLite via test suite can work since it doesn't support renaming multiple columns at once + Schema::table('posts', function (Blueprint $table) { + $table->renameColumn('image_id', 'banner_image_url'); + }); + + Schema::table('posts', function (Blueprint $table) { + $table->dropForeign('posts_image_id_foreign'); + $table->dropIndex('posts_image_id_foreign'); + }); + + Schema::table('posts', function (Blueprint $table) { + $table->string('banner_image_url')->change(); + }); + } +}; diff --git a/src/resources/js/glass.js b/src/resources/js/glass.js index 1189ed8..52fa0f3 100644 --- a/src/resources/js/glass.js +++ b/src/resources/js/glass.js @@ -9,8 +9,8 @@ export function slugifyField(textfield, slugField) { } // selectImage inserts the image name into the url field of the form once selected from a modal -export function selectImage(imageName) { - document.getElementById("banner_image_url").value = imageName; +export function selectImage(imageId, imageName) { + document.getElementById("image_id").value = imageId; document.getElementById( "banner-image-preview" ).src = `${window.location.origin}/storage/images/posts/${imageName}`; diff --git a/src/resources/views/admin.blade.php b/src/resources/views/admin.blade.php index 2aa0de5..0c48180 100644 --- a/src/resources/views/admin.blade.php +++ b/src/resources/views/admin.blade.php @@ -167,9 +167,11 @@ class="btn btn-sm btn-primary inline-block">Update @endphp