summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorThomas Grothe <grothe.tr@gmail.com>2026-04-11 17:49:42 -0400
committerThomas Grothe <grothe.tr@gmail.com>2026-04-11 17:49:42 -0400
commitd4f97aa956be051dd5b9a184557106dc7de112ac (patch)
treee4c73c93408b5fbc4cc1b92cabcaceaaebbb1833
parentbcac54576d7309ac0471a7be5664c5a4e8d7349e (diff)
parent054c19bf65beb43d0dd6137f9bf16cf8ca9f6190 (diff)
Merge remote-tracking branch 'origin/main'
-rw-r--r--README.md10
-rw-r--r--app/Http/Controllers/AdminController.php32
-rw-r--r--app/Http/Controllers/Auth/RegisteredUserController.php10
-rwxr-xr-xapp/Http/Controllers/Controller.php4
-rw-r--r--app/Http/Controllers/DashboardController.php27
-rw-r--r--app/Http/Controllers/FileController.php21
-rw-r--r--app/Http/Controllers/InventoryController.php65
-rw-r--r--app/Http/Controllers/ItemController.php65
-rw-r--r--app/Http/Controllers/LinkController.php131
-rw-r--r--app/Http/Controllers/QuoteController.php68
-rw-r--r--app/Http/Controllers/RoleController.php65
-rw-r--r--app/Http/Controllers/SiteController.php24
-rw-r--r--app/Http/Controllers/TagController.php65
-rw-r--r--app/Http/Controllers/TransactionController.php65
-rw-r--r--app/Http/Controllers/WritingController.php35
-rw-r--r--app/Http/Middleware/Admin.php43
-rw-r--r--app/Http/Middleware/Logger.php33
-rw-r--r--app/Http/Requests/StoreLinkRequest.php6
-rw-r--r--app/Http/Requests/StoreQuoteRequest.php2
-rw-r--r--app/Http/Requests/UpdateLinkRequest.php6
-rw-r--r--app/Http/Requests/UpdateQuoteRequest.php2
-rw-r--r--app/Models/Inventory.php10
-rw-r--r--app/Models/Item.php10
-rw-r--r--app/Models/Link.php4
-rw-r--r--app/Models/Quote.php2
-rw-r--r--app/Models/Tag.php1
-rw-r--r--app/Models/Transaction.php10
-rwxr-xr-xapp/Models/User.php12
-rw-r--r--app/Models/VisitorLog.php27
-rw-r--r--app/Notifications/AdminAccessNotification.php39
-rw-r--r--app/Notifications/NewUserNotification.php36
-rw-r--r--app/Policies/FilePolicy.php49
-rw-r--r--app/Policies/LinkPolicy.php49
-rw-r--r--app/Policies/WritingPolicy.php49
-rwxr-xr-xapp/Providers/AppServiceProvider.php18
-rwxr-xr-xconfig/app.php12
-rw-r--r--database/factories/WritingFactory.php3
-rw-r--r--database/migrations/2025_01_04_021047_inventory.php24
-rw-r--r--database/migrations/2025_01_04_021054_transaction.php24
-rw-r--r--database/migrations/2025_01_04_021120_item.php24
-rw-r--r--database/migrations/2026_02_15_000000_create_visitor_logs_table.php35
-rw-r--r--database/migrations/2026_03_05_224025_create_link_tag_table.php31
-rw-r--r--database/migrations/2026_03_06_171040_fix_links_table_description_nullable_and_fk.php50
-rw-r--r--database/migrations/2026_03_06_171913_fix_pivot_table_fk_types.php98
-rw-r--r--database/migrations/2026_03_06_181900_fix_writings_user_id_fk_type.php48
-rw-r--r--database/migrations/99999_pivots.php79
-rw-r--r--docs/hype.md3
-rwxr-xr-xpackage.json6
-rwxr-xr-xphpunit.xml4
-rw-r--r--public/css/home.css8
-rw-r--r--public/css/writings.css1
-rw-r--r--public/js/main.js10
-rw-r--r--public/js/writing_create.js86
-rw-r--r--resources/js/app.js15
-rw-r--r--resources/js/writing_create.js67
-rw-r--r--resources/views/admin.blade.php268
-rw-r--r--resources/views/dashboard.blade.php130
-rwxr-xr-xresources/views/home.blade.php3
-rw-r--r--resources/views/links/create.blade.php71
-rw-r--r--resources/views/links/index.blade.php4
-rw-r--r--resources/views/links/show.blade.php1
-rw-r--r--resources/views/marked.blade.php5
-rwxr-xr-xresources/views/template.blade.php15
-rw-r--r--resources/views/writings/create.blade.php6
-rw-r--r--resources/views/writings/edit.blade.php4
-rw-r--r--resources/views/writings/show.blade.php17
-rwxr-xr-xroutes/web.php104
-rw-r--r--tests/Feature/WritingTest.php279
-rwxr-xr-xvite.config.js16
69 files changed, 1752 insertions, 894 deletions
diff --git a/README.md b/README.md
index aab7fed..28693d5 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,7 @@
-status 2/13: websocket situation is working. this laravel app hosts javascript client code that connects to an external websocket server, which ideally is run on the same server as this laravel app.
-
here is the websocket app: https://github.com/grothedev/websocket-template
run the server and set the WS_URL env variable of this laravel app appropriately
-https://laravel.com/api/11.x/
-
-https://laravel.com/docs/11.x/blade
-
-#### TODO:
+#### TODO:
- a site statistics page
- total visits, visit frequency, country of recent visits, average location makeup,
- current connections, time spent on site (average, min, max, )
@@ -21,6 +15,7 @@ https://laravel.com/docs/11.x/blade
- make /htmx/{thing}/{param} routes for sub-elements of pages? e.g. /links/# gives a blade page, but /htmx/links/# would give just the single html element (and maybe would be queried by /links/#, though it might be silly to have to require a double request like that, when the php can already do the necessary processing in blade)
- error handling and validation for file upload and everything
- writing
+ - fix the preview which stopped working
- add drag-drop file to upload and then generate appropriate html to embed that file
- auto capitalization https://marked.js.org/using_pro
- index filter and sort
@@ -36,7 +31,6 @@ https://laravel.com/docs/11.x/blade
- general
- import any model via json
- - links
- break out the canvas cursor renderer from main.js
- and more js organizination: treat each js file like a "plugin" that provides some functionality to some page if the page has the appropriate elements on it. define what dom elems are needed and optional for some js file. this way it won't be coupled with the page as much.
- homepage image
diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php
new file mode 100644
index 0000000..84ecdf3
--- /dev/null
+++ b/app/Http/Controllers/AdminController.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\VisitorLog;
+use App\Models\File;
+use App\Models\Link;
+use Illuminate\Http\Request;
+
+class AdminController extends Controller
+{
+ /*
+ * Show the admin panel.
+ */
+ public function index()
+ {
+ $visitors = VisitorLog::orderBy('last_seen_at', 'desc')->get();
+ $files = File::orderBy('created_at', 'desc')->get();
+ $links = Link::orderBy('created_at', 'desc')->get();
+
+ $visitorSummary = [
+ 'unique_ips' => $visitors->count(),
+ 'total_requests' => $visitors->sum('total_requests'),
+ 'total_200' => $visitors->sum('status_200_count'),
+ 'total_404' => $visitors->sum('status_404_count'),
+ 'total_405' => $visitors->sum('status_405_count'),
+ 'total_500' => $visitors->sum('status_500_count'),
+ ];
+
+ return view('admin', compact('visitors', 'files', 'links', 'visitorSummary'));
+ }
+}
diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php
index 0739e2e..3878c06 100644
--- a/app/Http/Controllers/Auth/RegisteredUserController.php
+++ b/app/Http/Controllers/Auth/RegisteredUserController.php
@@ -11,6 +11,8 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\View\View;
+use Illuminate\Support\Facades\Notification;
+use App\Notifications\NewUserNotification;
class RegisteredUserController extends Controller
{
@@ -43,6 +45,14 @@ class RegisteredUserController extends Controller
event(new Registered($user));
+ // Notify admin of new registration
+ $adminEmail = config('app.admin_notify_email');
+ if ($adminEmail) {
+ Notification::route('mail', $adminEmail)->notify(
+ new NewUserNotification($user)
+ );
+ }
+
Auth::login($user);
return redirect(route('dashboard', absolute: false));
diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php
index 8677cd5..e7f7c94 100755
--- a/app/Http/Controllers/Controller.php
+++ b/app/Http/Controllers/Controller.php
@@ -2,7 +2,9 @@
namespace App\Http\Controllers;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+
abstract class Controller
{
- //
+ use AuthorizesRequests;
}
diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php
index 7335629..c104e77 100644
--- a/app/Http/Controllers/DashboardController.php
+++ b/app/Http/Controllers/DashboardController.php
@@ -16,10 +16,29 @@ class DashboardController extends Controller
{
$user = Auth::user();
- // You can add any additional data preparation here
- // before passing to the view
-
- return view('dashboard');
+ // Storage stats
+ $usedStorage = $user->getStorageUsed();
+ $totalStorage = $user->storage_quota;
+ $storagePercent = $totalStorage > 0 ? min(100, round(($usedStorage / $totalStorage) * 100)) : 0;
+
+ // File stats
+ $fileCount = 0; // TODO: update when DB schema supports user->files()
+ $recentFiles = collect();
+
+ // Writing stats
+ $writingCount = $user->writings()->count();
+ $recentWritings = $user->writings()->orderBy('created_at', 'desc')->take(3)->get();
+
+ return view('dashboard', compact(
+ 'user',
+ 'usedStorage',
+ 'totalStorage',
+ 'storagePercent',
+ 'fileCount',
+ 'recentFiles',
+ 'writingCount',
+ 'recentWritings',
+ ));
}
/**
diff --git a/app/Http/Controllers/FileController.php b/app/Http/Controllers/FileController.php
index 5283ad5..70d07ef 100644
--- a/app/Http/Controllers/FileController.php
+++ b/app/Http/Controllers/FileController.php
@@ -59,7 +59,7 @@ class FileController extends Controller
// Get all available tags for the tagging UI
$allTags = Tag::orderBy('label')->get();
- return view('files.index', compact('fileData', 'allTags', 'currentDir'));
+ return view('f', compact('fileData', 'allTags', 'currentDir'));
}
/**
@@ -132,7 +132,8 @@ class FileController extends Controller
// Return based on requested response format
if ($request->input('response_format') === 'html') {
- return redirect()->back()->with('success', "{$results['num_uploaded']} files uploaded successfully");
+ $redirect = $request->input('_redirect', url()->previous());
+ return redirect($redirect)->with('success', "{$results['num_uploaded']} files uploaded successfully");
}
return response()->json($results);
@@ -144,7 +145,8 @@ class FileController extends Controller
public function addTags(Request $request, $fileId)
{
$file = File::findOrFail($fileId);
-
+ $this->authorize('update', $file);
+
$validated = $request->validate([
'tags' => 'required|string'
]);
@@ -168,6 +170,8 @@ class FileController extends Controller
public function removeTag(Request $request, $fileId, $tagId)
{
$file = File::findOrFail($fileId);
+ $this->authorize('update', $file);
+
$tag = Tag::findOrFail($tagId);
$file->tags()->detach($tagId);
@@ -195,6 +199,8 @@ class FileController extends Controller
*/
public function update(Request $request, File $file)
{
+ $this->authorize('update', $file);
+
$validated = $request->validate([
'description' => 'nullable|string',
'tags' => 'nullable|string'
@@ -217,6 +223,8 @@ class FileController extends Controller
*/
public function destroy(File $file)
{
+ $this->authorize('delete', $file);
+
// Remove the file from storage
if (Storage::disk('public')->exists($file->path)) {
Storage::disk('public')->delete($file->path);
@@ -224,8 +232,9 @@ class FileController extends Controller
// Delete from database (will auto detach tags)
$file->delete();
-
- return redirect()->route('files.index')->with('success', 'File deleted successfully');
+
+ $redirect = request()->input('_redirect', route('f.index'));
+ return redirect($redirect)->with('success', 'File deleted successfully');
}
/**
@@ -258,4 +267,4 @@ class FileController extends Controller
return view('files.by-tag', compact('tag', 'files'));
}
-} \ No newline at end of file
+}
diff --git a/app/Http/Controllers/InventoryController.php b/app/Http/Controllers/InventoryController.php
deleted file mode 100644
index 267a032..0000000
--- a/app/Http/Controllers/InventoryController.php
+++ /dev/null
@@ -1,65 +0,0 @@
-<?php
-
-namespace App\Http\Controllers;
-
-use App\Models\Inventory;
-use Illuminate\Http\Request;
-
-class InventoryController extends Controller
-{
- /**
- * Display a listing of the resource.
- */
- public function index()
- {
- //
- }
-
- /**
- * Show the form for creating a new resource.
- */
- public function create()
- {
- //
- }
-
- /**
- * Store a newly created resource in storage.
- */
- public function store(Request $request)
- {
- //
- }
-
- /**
- * Display the specified resource.
- */
- public function show(Inventory $inventory)
- {
- //
- }
-
- /**
- * Show the form for editing the specified resource.
- */
- public function edit(Inventory $inventory)
- {
- //
- }
-
- /**
- * Update the specified resource in storage.
- */
- public function update(Request $request, Inventory $inventory)
- {
- //
- }
-
- /**
- * Remove the specified resource from storage.
- */
- public function destroy(Inventory $inventory)
- {
- //
- }
-}
diff --git a/app/Http/Controllers/ItemController.php b/app/Http/Controllers/ItemController.php
deleted file mode 100644
index 6b049c9..0000000
--- a/app/Http/Controllers/ItemController.php
+++ /dev/null
@@ -1,65 +0,0 @@
-<?php
-
-namespace App\Http\Controllers;
-
-use App\Models\Item;
-use Illuminate\Http\Request;
-
-class ItemController extends Controller
-{
- /**
- * Display a listing of the resource.
- */
- public function index()
- {
- //
- }
-
- /**
- * Show the form for creating a new resource.
- */
- public function create()
- {
- //
- }
-
- /**
- * Store a newly created resource in storage.
- */
- public function store(Request $request)
- {
- //
- }
-
- /**
- * Display the specified resource.
- */
- public function show(Item $item)
- {
- //
- }
-
- /**
- * Show the form for editing the specified resource.
- */
- public function edit(Item $item)
- {
- //
- }
-
- /**
- * Update the specified resource in storage.
- */
- public function update(Request $request, Item $item)
- {
- //
- }
-
- /**
- * Remove the specified resource from storage.
- */
- public function destroy(Item $item)
- {
- //
- }
-}
diff --git a/app/Http/Controllers/LinkController.php b/app/Http/Controllers/LinkController.php
index 278916c..e889489 100644
--- a/app/Http/Controllers/LinkController.php
+++ b/app/Http/Controllers/LinkController.php
@@ -5,6 +5,11 @@ namespace App\Http\Controllers;
use App\Http\Requests\StoreLinkRequest;
use App\Http\Requests\UpdateLinkRequest;
use App\Models\Link;
+use App\Models\Tag;
+use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Http\Request;
class LinkController extends Controller
{
@@ -29,17 +34,16 @@ class LinkController extends Controller
*/
public function store(StoreLinkRequest $req)
{
- //TODO tag ids
- $validated = $req->validate([
- 'label' => 'min:2|max:255',
- 'url' => 'min:3'
- ]);
+ $validated = $req->validated();
$link = Link::create([
'label' => $validated['label'],
'url' => $validated['url'],
- 'description' => $validated['description'],
+ 'description' => $validated['description'] ?? '',
+ 'user_id' => Auth::id(),
]);
- return redirect()->route('links.index')->with('success', 'link added');
+
+ $redirect = $req->input('_redirect', route('l.index'));
+ return redirect($redirect)->with('success', 'Link added');
}
/**
@@ -66,7 +70,13 @@ class LinkController extends Controller
*/
public function update(UpdateLinkRequest $request, Link $link)
{
- //
+ $this->authorize('update', $link);
+
+ $validated = $request->validated();
+ $link->update($validated);
+
+ $redirect = $request->input('_redirect', route('l.index'));
+ return redirect($redirect)->with('success', 'Link updated');
}
/**
@@ -74,6 +84,109 @@ class LinkController extends Controller
*/
public function destroy(Link $link)
{
- //
+ $this->authorize('delete', $link);
+
+ $link->delete();
+
+ $redirect = request()->input('_redirect', route('l.index'));
+ return redirect($redirect)->with('success', 'Link deleted');
+ }
+
+ /**
+ * Import links from an uploaded JSON file. Admin only.
+ */
+ public function import(Request $request)
+ {
+ $request->validate([
+ 'json_file' => 'required|file|max:2048',
+ ]);
+
+ $file = $request->file('json_file');
+
+ if (!in_array($file->getClientOriginalExtension(), ['json', 'txt'])) {
+ return back()->withErrors(['json_file' => 'File must be a .json or .txt file.']);
+ }
+
+ $contents = file_get_contents($file->getRealPath());
+ $entries = json_decode($contents, true);
+
+ if (json_last_error() !== JSON_ERROR_NONE) {
+ return back()->withErrors(['json_file' => 'Invalid JSON: ' . json_last_error_msg()]);
+ }
+
+ if (!is_array($entries)) {
+ return back()->withErrors(['json_file' => 'JSON must contain an array of link objects.']);
+ }
+
+ if (count($entries) > 500) {
+ return back()->withErrors(['json_file' => 'Too many entries. Maximum is 500 per upload.']);
+ }
+
+ $created = 0;
+ $skipped = 0;
+ $errors = [];
+
+ DB::transaction(function () use ($entries, &$created, &$skipped, &$errors) {
+ foreach ($entries as $i => $entry) {
+ if (empty($entry['url'])) {
+ $errors[] = "Entry #{$i}: missing url, skipped.";
+ $skipped++;
+ continue;
+ }
+
+ $entryValidator = Validator::make($entry, [
+ 'url' => 'required|string|url|max:255',
+ 'label' => 'nullable|string|min:2|max:255',
+ 'description' => 'nullable|string|max:255',
+ 'tags' => 'nullable|array|max:20',
+ 'tags.*' => 'string|max:50',
+ ]);
+
+ if ($entryValidator->fails()) {
+ $reasons = implode(', ', $entryValidator->errors()->all());
+ $errors[] = "Entry #{$i} ({$entry['url']}): {$reasons}";
+ $skipped++;
+ continue;
+ }
+
+ $validated = $entryValidator->validated();
+
+ $existing = Link::where('url', $validated['url'])->first();
+ if ($existing) {
+ $skipped++;
+ continue;
+ }
+
+ $link = Link::create([
+ 'url' => $validated['url'],
+ 'label' => $validated['label'] ?? $validated['url'],
+ 'description' => $validated['description'] ?? '',
+ 'user_id' => Auth::id(),
+ ]);
+
+ // Attach tags if present
+ if (!empty($validated['tags']) && is_array($validated['tags'])) {
+ $tagIds = [];
+ foreach ($validated['tags'] as $tagName) {
+ if (is_string($tagName) && trim($tagName) !== '') {
+ $tag = Tag::firstOrCreate(['label' => trim($tagName)]);
+ $tagIds[] = $tag->id;
+ }
+ }
+ if (!empty($tagIds)) {
+ $link->tags()->syncWithoutDetaching($tagIds);
+ }
+ }
+
+ $created++;
+ }
+ });
+
+ $message = "{$created} link(s) imported, {$skipped} skipped (duplicates or invalid).";
+ if (!empty($errors)) {
+ $message .= ' Warnings: ' . implode(' ', array_slice($errors, 0, 5));
+ }
+
+ return redirect()->route('l.index')->with('success', $message);
}
}
diff --git a/app/Http/Controllers/QuoteController.php b/app/Http/Controllers/QuoteController.php
deleted file mode 100644
index 0803937..0000000
--- a/app/Http/Controllers/QuoteController.php
+++ /dev/null
@@ -1,68 +0,0 @@
-<?php
-
-namespace App\Http\Controllers;
-
-use App\Http\Requests\StoreQuoteRequest;
-use App\Http\Requests\UpdateQuoteRequest;
-use App\Models\Quote;
-
-class QuoteController extends Controller
-{
- /**
- * Display a listing of the resource.
- */
- public function index()
- {
- //
- }
-
- /**
- * Show the form for creating a new resource.
- */
- public function create()
- {
- //
- }
-
- /**
- * Store a newly created resource in storage.
- */
- public function store(StoreQuoteRequest $request)
- {
- //
- $quote = Quote::create($request->validated());
-
- }
-
- /**
- * Display the specified resource.
- */
- public function show(Quote $quote)
- {
- //
- }
-
- /**
- * Show the form for editing the specified resource.
- */
- public function edit(Quote $quote)
- {
- //
- }
-
- /**
- * Update the specified resource in storage.
- */
- public function update(UpdateQuoteRequest $request, Quote $quote)
- {
- //
- }
-
- /**
- * Remove the specified resource from storage.
- */
- public function destroy(Quote $quote)
- {
- //
- }
-}
diff --git a/app/Http/Controllers/RoleController.php b/app/Http/Controllers/RoleController.php
deleted file mode 100644
index 9dd7397..0000000
--- a/app/Http/Controllers/RoleController.php
+++ /dev/null
@@ -1,65 +0,0 @@
-<?php
-
-namespace App\Http\Controllers;
-
-use App\Models\Role;
-use Illuminate\Http\Request;
-
-class RoleController extends Controller
-{
- /**
- * Display a listing of the resource.
- */
- public function index()
- {
- //
- }
-
- /**
- * Show the form for creating a new resource.
- */
- public function create()
- {
- //
- }
-
- /**
- * Store a newly created resource in storage.
- */
- public function store(Request $request)
- {
- //
- }
-
- /**
- * Display the specified resource.
- */
- public function show(Role $role)
- {
- //
- }
-
- /**
- * Show the form for editing the specified resource.
- */
- public function edit(Role $role)
- {
- //
- }
-
- /**
- * Update the specified resource in storage.
- */
- public function update(Request $request, Role $role)
- {
- //
- }
-
- /**
- * Remove the specified resource from storage.
- */
- public function destroy(Role $role)
- {
- //
- }
-}
diff --git a/app/Http/Controllers/SiteController.php b/app/Http/Controllers/SiteController.php
index a970bde..33d7dc3 100644
--- a/app/Http/Controllers/SiteController.php
+++ b/app/Http/Controllers/SiteController.php
@@ -6,7 +6,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
-use Storage;
+use Illuminate\Support\Facades\Storage;
use Carbon\Carbon;
class SiteController extends Controller
@@ -18,10 +18,7 @@ class SiteController extends Controller
'Link',
'Tag',
'Writing',
- 'Inventory',
- 'Item',
'Role',
- 'Transaction'
];
public static $MODELS_LC_PLURAL = [
'users',
@@ -29,10 +26,7 @@ class SiteController extends Controller
'links',
'tags',
'writings',
- 'inventories',
- 'items',
'roles',
- 'transactions'
];
//returns an env mapping to be used by client. pretty much a subset of laravel app's env, but might also have addition things
@@ -83,7 +77,7 @@ class SiteController extends Controller
foreach ($files as $f){
$filename = $f->getClientOriginalName();
- if (Storage::disk('public')->fileExists("${dir}/${filename}")){
+ if (Storage::disk('public')->exists("${dir}/${filename}")){
$filename = Carbon::now()->timestamp.'_'.$filename;
}
$path = $f->storeAs(
@@ -112,17 +106,13 @@ class SiteController extends Controller
public function search4chan(Request $req){
$query = $req->input('query');
- $board = "";
Log::info('search4chan()');
- if ( $req->input('board') != null) {
- $board = $req->input('board').'/'.$query;
- }
- $cmd = "python ./4chansearch.py ${query}";
- if ( $board != "" ){
- $cmd .= "-b ${board}";
+
+ $cmd = 'python ./4chansearch.py ' . escapeshellarg($query);
+ if ($req->input('board') != null) {
+ $cmd .= ' -b ' . escapeshellarg($req->input('board'));
}
- $shellcmd = escapeshellcmd($cmd);
- exec($shellcmd, $res, $ret);
+ exec($cmd, $res, $ret);
return $res;
}
diff --git a/app/Http/Controllers/TagController.php b/app/Http/Controllers/TagController.php
deleted file mode 100644
index 754c75a..0000000
--- a/app/Http/Controllers/TagController.php
+++ /dev/null
@@ -1,65 +0,0 @@
-<?php
-
-namespace App\Http\Controllers;
-
-use App\Models\Tag;
-use Illuminate\Http\Request;
-
-class TagController extends Controller
-{
- /**
- * Display a listing of the resource.
- */
- public function index()
- {
- //
- }
-
- /**
- * Show the form for creating a new resource.
- */
- public function create()
- {
- //
- }
-
- /**
- * Store a newly created resource in storage.
- */
- public function store(Request $request)
- {
- //
- }
-
- /**
- * Display the specified resource.
- */
- public function show(Tag $tag)
- {
- //
- }
-
- /**
- * Show the form for editing the specified resource.
- */
- public function edit(Tag $tag)
- {
- //
- }
-
- /**
- * Update the specified resource in storage.
- */
- public function update(Request $request, Tag $tag)
- {
- //
- }
-
- /**
- * Remove the specified resource from storage.
- */
- public function destroy(Tag $tag)
- {
- //
- }
-}
diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php
deleted file mode 100644
index 2d9f3c3..0000000
--- a/app/Http/Controllers/TransactionController.php
+++ /dev/null
@@ -1,65 +0,0 @@
-<?php
-
-namespace App\Http\Controllers;
-
-use App\Models\Transaction;
-use Illuminate\Http\Request;
-
-class TransactionController extends Controller
-{
- /**
- * Display a listing of the resource.
- */
- public function index()
- {
- //
- }
-
- /**
- * Show the form for creating a new resource.
- */
- public function create()
- {
- //
- }
-
- /**
- * Store a newly created resource in storage.
- */
- public function store(Request $request)
- {
- //
- }
-
- /**
- * Display the specified resource.
- */
- public function show(Transaction $transaction)
- {
- //
- }
-
- /**
- * Show the form for editing the specified resource.
- */
- public function edit(Transaction $transaction)
- {
- //
- }
-
- /**
- * Update the specified resource in storage.
- */
- public function update(Request $request, Transaction $transaction)
- {
- //
- }
-
- /**
- * Remove the specified resource from storage.
- */
- public function destroy(Transaction $transaction)
- {
- //
- }
-}
diff --git a/app/Http/Controllers/WritingController.php b/app/Http/Controllers/WritingController.php
index 688f478..b99d6bb 100644
--- a/app/Http/Controllers/WritingController.php
+++ b/app/Http/Controllers/WritingController.php
@@ -15,11 +15,8 @@ class WritingController extends Controller
*/
public function index()
{
- Log::info('w index');
- $writings = Writing::all();
- Log::info($writings);
return view('writings.index', [
- 'writings' => Writing::orderBy('created_at')->paginate(20)
+ 'writings' => Writing::orderBy('created_at', 'desc')->paginate(20)
]);
}
@@ -40,14 +37,13 @@ class WritingController extends Controller
'title' => 'required|min:3|max:255',
'content' => 'required|min:10',
]);
-
-
+
$writing = Writing::create([
'title' => $validated['title'],
'content' => $validated['content'],
- 'user_id' => Auth::user() == null ? null : Auth::id(),
+ 'user_id' => Auth::id(),
]);
-
+
return redirect()->route('w.show', $writing)
->with('success', 'Writing created successfully!');
}
@@ -57,7 +53,7 @@ class WritingController extends Controller
*/
public function show($id)
{
- $writing = Writing::findOrFail($id);
+ $writing = Writing::with('user')->findOrFail($id);
return view('writings.show', [
'writing' => $writing
]);
@@ -69,6 +65,8 @@ class WritingController extends Controller
public function edit($id)
{
$writing = Writing::findOrFail($id);
+ $this->authorize('update', $writing);
+
return view('writings.edit', [
'writing' => $writing
]);
@@ -79,7 +77,17 @@ class WritingController extends Controller
*/
public function update(Request $request, Writing $writing)
{
-
+ $this->authorize('update', $writing);
+
+ $validated = $request->validate([
+ 'title' => 'required|min:3|max:255',
+ 'content' => 'required|min:10',
+ ]);
+
+ $writing->update($validated);
+
+ return redirect()->route('w.show', $writing)
+ ->with('success', 'Writing updated successfully!');
}
/**
@@ -87,6 +95,11 @@ class WritingController extends Controller
*/
public function destroy(Writing $writing)
{
- return 'todo';
+ $this->authorize('delete', $writing);
+
+ $writing->delete();
+
+ return redirect()->route('w.index')
+ ->with('success', 'Writing deleted.');
}
}
diff --git a/app/Http/Middleware/Admin.php b/app/Http/Middleware/Admin.php
new file mode 100644
index 0000000..65fba8c
--- /dev/null
+++ b/app/Http/Middleware/Admin.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace App\Http\Middleware;
+
+use Closure;
+use Illuminate\Http\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Notification;
+use App\Notifications\AdminAccessNotification;
+use Illuminate\Notifications\AnonymousNotifiable;
+
+class Admin
+{
+ /**
+ * Ensure the authenticated user is an admin (role == 0).
+ */
+ public function handle(Request $request, Closure $next): Response
+ {
+ if (!Auth::check() || !Auth::user()->isAdmin()) {
+ abort(403, 'Unauthorized');
+ }
+
+ // Notify admin email of access — throttled to once per 15 minutes per user+IP
+ $adminEmail = config('app.admin_notify_email');
+ if ($adminEmail) {
+ $cacheKey = 'admin_notify:' . Auth::id() . ':' . $request->ip();
+ if (!Cache::has($cacheKey)) {
+ Notification::route('mail', $adminEmail)->notify(
+ new AdminAccessNotification(
+ Auth::user()->name,
+ $request->ip(),
+ $request->header('User-Agent', 'unknown')
+ )
+ );
+ Cache::put($cacheKey, true, now()->addMinutes(15));
+ }
+ }
+
+ return $next($request);
+ }
+}
diff --git a/app/Http/Middleware/Logger.php b/app/Http/Middleware/Logger.php
index ef707f4..17c2f18 100644
--- a/app/Http/Middleware/Logger.php
+++ b/app/Http/Middleware/Logger.php
@@ -6,6 +6,7 @@ use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Illuminate\Support\Facades\Log;
+use App\Models\VisitorLog;
class Logger
{
@@ -18,17 +19,45 @@ class Logger
{
$response = $next($request);
+ $statusCode = $response->getStatusCode();
+
$logMessage = sprintf(
"[%s] IP: %s | Method: %s | Route: %s | Status: %s | Agent: %s",
now()->format('Y-m-d H:i:s'),
$request->ip(),
$request->method(),
$request->path(),
- $response->getStatusCode(),
+ $statusCode,
$request->header('User-Agent')
);
- Log::channel('requests')->info($logMessage);
+ try {
+ Log::channel('requests')->info($logMessage);
+ } catch (\Exception $e) {
+ // Log channel unavailable (e.g. permission error in test environment)
+ }
+
+ // Update visitor log in database
+ $statusColumn = match ($statusCode) {
+ 200 => 'status_200_count',
+ 404 => 'status_404_count',
+ 405 => 'status_405_count',
+ 500 => 'status_500_count',
+ default => null,
+ };
+
+ $visitor = VisitorLog::firstOrCreate(
+ ['ip_address' => $request->ip()],
+ ['first_seen_at' => now(), 'total_requests' => 0]
+ );
+
+ $visitor->increment('total_requests');
+
+ if ($statusColumn) {
+ $visitor->increment($statusColumn);
+ }
+
+ $visitor->update(['last_seen_at' => now()]);
return $response;
}
diff --git a/app/Http/Requests/StoreLinkRequest.php b/app/Http/Requests/StoreLinkRequest.php
index b3e52e1..250c98d 100644
--- a/app/Http/Requests/StoreLinkRequest.php
+++ b/app/Http/Requests/StoreLinkRequest.php
@@ -11,7 +11,7 @@ class StoreLinkRequest extends FormRequest
*/
public function authorize(): bool
{
- return false;
+ return true;
}
/**
@@ -22,7 +22,9 @@ class StoreLinkRequest extends FormRequest
public function rules(): array
{
return [
- //
+ 'label' => 'required|string|min:2|max:255',
+ 'url' => 'required|string|min:3|max:255',
+ 'description' => 'nullable|string|max:255',
];
}
}
diff --git a/app/Http/Requests/StoreQuoteRequest.php b/app/Http/Requests/StoreQuoteRequest.php
index e9859fb..68f1158 100644
--- a/app/Http/Requests/StoreQuoteRequest.php
+++ b/app/Http/Requests/StoreQuoteRequest.php
@@ -11,7 +11,7 @@ class StoreQuoteRequest extends FormRequest
*/
public function authorize(): bool
{
- return false;
+ return true;
}
/**
diff --git a/app/Http/Requests/UpdateLinkRequest.php b/app/Http/Requests/UpdateLinkRequest.php
index 1d70c9a..22ff43c 100644
--- a/app/Http/Requests/UpdateLinkRequest.php
+++ b/app/Http/Requests/UpdateLinkRequest.php
@@ -11,7 +11,7 @@ class UpdateLinkRequest extends FormRequest
*/
public function authorize(): bool
{
- return false;
+ return true;
}
/**
@@ -22,7 +22,9 @@ class UpdateLinkRequest extends FormRequest
public function rules(): array
{
return [
- //
+ 'label' => 'sometimes|string|min:2|max:255',
+ 'url' => 'sometimes|string|min:3|max:255',
+ 'description' => 'nullable|string|max:255',
];
}
}
diff --git a/app/Http/Requests/UpdateQuoteRequest.php b/app/Http/Requests/UpdateQuoteRequest.php
index 93804a8..9a2e50d 100644
--- a/app/Http/Requests/UpdateQuoteRequest.php
+++ b/app/Http/Requests/UpdateQuoteRequest.php
@@ -11,7 +11,7 @@ class UpdateQuoteRequest extends FormRequest
*/
public function authorize(): bool
{
- return false;
+ return true;
}
/**
diff --git a/app/Models/Inventory.php b/app/Models/Inventory.php
deleted file mode 100644
index be2bc0f..0000000
--- a/app/Models/Inventory.php
+++ /dev/null
@@ -1,10 +0,0 @@
-<?php
-
-namespace App\Models;
-
-use Illuminate\Database\Eloquent\Model;
-
-class Inventory extends Model
-{
- //
-}
diff --git a/app/Models/Item.php b/app/Models/Item.php
deleted file mode 100644
index 3b92220..0000000
--- a/app/Models/Item.php
+++ /dev/null
@@ -1,10 +0,0 @@
-<?php
-
-namespace App\Models;
-
-use Illuminate\Database\Eloquent\Model;
-
-class Item extends Model
-{
- //
-}
diff --git a/app/Models/Link.php b/app/Models/Link.php
index 6f3648c..619a3e3 100644
--- a/app/Models/Link.php
+++ b/app/Models/Link.php
@@ -3,12 +3,10 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
-use App\Models\Traits\AutoFillable;
class Link extends Model
{
- //use AutoFillable;
- public $fillable = ['label', 'url', 'description', 'user_id'];
+ protected $fillable = ['label', 'url', 'description', 'user_id'];
public function tags(){
return $this->belongsToMany(Tag::class);
diff --git a/app/Models/Quote.php b/app/Models/Quote.php
index f340ba9..3b93ddb 100644
--- a/app/Models/Quote.php
+++ b/app/Models/Quote.php
@@ -10,7 +10,7 @@ class Quote extends Model
/** @use HasFactory<\Database\Factories\QuoteFactory> */
use HasFactory;
- public $fillable = [
+ protected $fillable = [
'author',
'text',
'source',
diff --git a/app/Models/Tag.php b/app/Models/Tag.php
index 9617222..e00aea8 100644
--- a/app/Models/Tag.php
+++ b/app/Models/Tag.php
@@ -7,6 +7,7 @@ use App\Models\Traits\AutoFillable;
class Tag extends Model
{
+ protected $fillable = ['label'];
public function links(){
return $this->belongsToMany(Link::class);
diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php
deleted file mode 100644
index 32b210a..0000000
--- a/app/Models/Transaction.php
+++ /dev/null
@@ -1,10 +0,0 @@
-<?php
-
-namespace App\Models;
-
-use Illuminate\Database\Eloquent\Model;
-
-class Transaction extends Model
-{
- //
-}
diff --git a/app/Models/User.php b/app/Models/User.php
index 4aeaf21..b025bbe 100755
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -48,6 +48,7 @@ class User extends Authenticatable
'email_verified_at' => 'datetime',
'password' => 'hashed',
'status_updated_at' => 'datetime',
+ 'role' => 'integer'
];
}
@@ -65,12 +66,17 @@ class User extends Authenticatable
return $this->hasMany(Link::class);
}
+ public function isAdmin(): bool
+ {
+ return $this->role == 0;
+ }
+
public function getStorageQuota(){
- $roles = $this->roles();
+ $roles = $this->roles()->get();
$quota = 0;
foreach ($roles as $r){
- if ($role->storage_quota > $quota){
- $quota = $role->storage_quota;
+ if ($r->storage_quota > $quota){
+ $quota = $r->storage_quota;
}
}
return $quota;
diff --git a/app/Models/VisitorLog.php b/app/Models/VisitorLog.php
new file mode 100644
index 0000000..067db45
--- /dev/null
+++ b/app/Models/VisitorLog.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class VisitorLog extends Model
+{
+ protected $fillable = [
+ 'ip_address',
+ 'total_requests',
+ 'status_200_count',
+ 'status_404_count',
+ 'status_405_count',
+ 'status_500_count',
+ 'first_seen_at',
+ 'last_seen_at',
+ ];
+
+ protected function casts(): array
+ {
+ return [
+ 'first_seen_at' => 'datetime',
+ 'last_seen_at' => 'datetime',
+ ];
+ }
+}
diff --git a/app/Notifications/AdminAccessNotification.php b/app/Notifications/AdminAccessNotification.php
new file mode 100644
index 0000000..8a5b847
--- /dev/null
+++ b/app/Notifications/AdminAccessNotification.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace App\Notifications;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Notifications\Messages\MailMessage;
+use Illuminate\Notifications\Notification;
+
+class AdminAccessNotification extends Notification implements ShouldQueue
+{
+ use Queueable;
+
+ protected string $ip;
+ protected string $userAgent;
+ protected string $userName;
+
+ public function __construct(string $userName, string $ip, string $userAgent)
+ {
+ $this->userName = $userName;
+ $this->ip = $ip;
+ $this->userAgent = $userAgent;
+ }
+
+ public function via(object $notifiable): array
+ {
+ return ['mail'];
+ }
+
+ public function toMail(object $notifiable): MailMessage
+ {
+ return (new MailMessage)
+ ->subject('Admin Panel Accessed')
+ ->line("The admin panel was accessed by **{$this->userName}**.")
+ ->line("IP: {$this->ip}")
+ ->line("User Agent: {$this->userAgent}")
+ ->line("Time: " . now()->format('Y-m-d H:i:s T'));
+ }
+}
diff --git a/app/Notifications/NewUserNotification.php b/app/Notifications/NewUserNotification.php
new file mode 100644
index 0000000..d2a9b6f
--- /dev/null
+++ b/app/Notifications/NewUserNotification.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Notifications;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Notifications\Messages\MailMessage;
+use Illuminate\Notifications\Notification;
+use App\Models\User;
+
+class NewUserNotification extends Notification implements ShouldQueue
+{
+ use Queueable;
+
+ protected User $newUser;
+
+ public function __construct(User $newUser)
+ {
+ $this->newUser = $newUser;
+ }
+
+ public function via(object $notifiable): array
+ {
+ return ['mail'];
+ }
+
+ public function toMail(object $notifiable): MailMessage
+ {
+ return (new MailMessage)
+ ->subject('New User Registered')
+ ->line("A new account has been created.")
+ ->line("Name: {$this->newUser->name}")
+ ->line("Email: {$this->newUser->email}")
+ ->line("Time: " . now()->format('Y-m-d H:i:s T'));
+ }
+}
diff --git a/app/Policies/FilePolicy.php b/app/Policies/FilePolicy.php
new file mode 100644
index 0000000..fe46f8b
--- /dev/null
+++ b/app/Policies/FilePolicy.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace App\Policies;
+
+use App\Models\File;
+use App\Models\User;
+
+class FilePolicy
+{
+ /**
+ * Anyone can view listings.
+ */
+ public function viewAny(?User $user): bool
+ {
+ return true;
+ }
+
+ /**
+ * Anyone can view a single file.
+ */
+ public function view(?User $user, File $file): bool
+ {
+ return true;
+ }
+
+ /**
+ * Any authenticated user can create files.
+ */
+ public function create(User $user): bool
+ {
+ return true;
+ }
+
+ /**
+ * Only the owner or an admin can update.
+ */
+ public function update(User $user, File $file): bool
+ {
+ return $user->id === $file->user_id || $user->isAdmin();
+ }
+
+ /**
+ * Only the owner or an admin can delete.
+ */
+ public function delete(User $user, File $file): bool
+ {
+ return $user->id === $file->user_id || $user->isAdmin();
+ }
+}
diff --git a/app/Policies/LinkPolicy.php b/app/Policies/LinkPolicy.php
new file mode 100644
index 0000000..1eba28f
--- /dev/null
+++ b/app/Policies/LinkPolicy.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace App\Policies;
+
+use App\Models\Link;
+use App\Models\User;
+
+class LinkPolicy
+{
+ /**
+ * Anyone can view listings.
+ */
+ public function viewAny(?User $user): bool
+ {
+ return true;
+ }
+
+ /**
+ * Anyone can view a single link.
+ */
+ public function view(?User $user, Link $link): bool
+ {
+ return true;
+ }
+
+ /**
+ * Any authenticated user can create links.
+ */
+ public function create(User $user): bool
+ {
+ return true;
+ }
+
+ /**
+ * Only the owner or an admin can update.
+ */
+ public function update(User $user, Link $link): bool
+ {
+ return $user->id === $link->user_id || $user->isAdmin();
+ }
+
+ /**
+ * Only the owner or an admin can delete.
+ */
+ public function delete(User $user, Link $link): bool
+ {
+ return $user->id === $link->user_id || $user->isAdmin();
+ }
+}
diff --git a/app/Policies/WritingPolicy.php b/app/Policies/WritingPolicy.php
new file mode 100644
index 0000000..e2bbaab
--- /dev/null
+++ b/app/Policies/WritingPolicy.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace App\Policies;
+
+use App\Models\User;
+use App\Models\Writing;
+
+class WritingPolicy
+{
+ /**
+ * Anyone can view listings.
+ */
+ public function viewAny(?User $user): bool
+ {
+ return true;
+ }
+
+ /**
+ * Anyone can view a single writing.
+ */
+ public function view(?User $user, Writing $writing): bool
+ {
+ return true;
+ }
+
+ /**
+ * Any authenticated user can create writings.
+ */
+ public function create(User $user): bool
+ {
+ return true;
+ }
+
+ /**
+ * Only the owner or an admin can update.
+ */
+ public function update(User $user, Writing $writing): bool
+ {
+ return $user->id === $writing->user_id || $user->isAdmin();
+ }
+
+ /**
+ * Only the owner or an admin can delete.
+ */
+ public function delete(User $user, Writing $writing): bool
+ {
+ return $user->id === $writing->user_id || $user->isAdmin();
+ }
+}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 452e6b6..8eee884 100755
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -3,6 +3,9 @@
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
+use Illuminate\Cache\RateLimiting\Limit;
+use Illuminate\Support\Facades\RateLimiter;
+use Illuminate\Http\Request;
class AppServiceProvider extends ServiceProvider
{
@@ -19,6 +22,19 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
- //
+ // Public routes: 60 requests per minute per IP
+ RateLimiter::for('public', function (Request $request) {
+ return Limit::perMinute(60)->by($request->ip());
+ });
+
+ // Admin routes: 30 requests per minute per authenticated user
+ RateLimiter::for('admin', function (Request $request) {
+ return Limit::perMinute(30)->by($request->user()?->id ?: $request->ip());
+ });
+
+ // Expensive operations (audio stream, file uploads): 5 per minute
+ RateLimiter::for('expensive', function (Request $request) {
+ return Limit::perMinute(5)->by($request->user()?->id ?: $request->ip());
+ });
}
}
diff --git a/config/app.php b/config/app.php
index f467267..fd51b9f 100755
--- a/config/app.php
+++ b/config/app.php
@@ -123,4 +123,16 @@ return [
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
+ /*
+ |--------------------------------------------------------------------------
+ | Admin Notification Email
+ |--------------------------------------------------------------------------
+ |
+ | Email address to receive admin notifications (new users, admin access).
+ | Set to null to disable notifications.
+ |
+ */
+
+ 'admin_notify_email' => env('ADMIN_NOTIFY_EMAIL', null),
+
];
diff --git a/database/factories/WritingFactory.php b/database/factories/WritingFactory.php
index 824ac82..a73f8ee 100644
--- a/database/factories/WritingFactory.php
+++ b/database/factories/WritingFactory.php
@@ -2,6 +2,7 @@
namespace Database\Factories;
+use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
@@ -19,7 +20,7 @@ class WritingFactory extends Factory
return [
'title' => fake()->sentence(4),
'content' => fake()->paragraphs(3, true),
- 'user_id' => fake()->numberBetween(1, 10),
+ 'user_id' => User::factory(),
];
}
}
diff --git a/database/migrations/2025_01_04_021047_inventory.php b/database/migrations/2025_01_04_021047_inventory.php
deleted file mode 100644
index 88fa2f3..0000000
--- a/database/migrations/2025_01_04_021047_inventory.php
+++ /dev/null
@@ -1,24 +0,0 @@
-<?php
-
-use Illuminate\Database\Migrations\Migration;
-use Illuminate\Database\Schema\Blueprint;
-use Illuminate\Support\Facades\Schema;
-
-return new class extends Migration
-{
- /**
- * Run the migrations.
- */
- public function up(): void
- {
- //
- }
-
- /**
- * Reverse the migrations.
- */
- public function down(): void
- {
- //
- }
-};
diff --git a/database/migrations/2025_01_04_021054_transaction.php b/database/migrations/2025_01_04_021054_transaction.php
deleted file mode 100644
index 88fa2f3..0000000
--- a/database/migrations/2025_01_04_021054_transaction.php
+++ /dev/null
@@ -1,24 +0,0 @@
-<?php
-
-use Illuminate\Database\Migrations\Migration;
-use Illuminate\Database\Schema\Blueprint;
-use Illuminate\Support\Facades\Schema;
-
-return new class extends Migration
-{
- /**
- * Run the migrations.
- */
- public function up(): void
- {
- //
- }
-
- /**
- * Reverse the migrations.
- */
- public function down(): void
- {
- //
- }
-};
diff --git a/database/migrations/2025_01_04_021120_item.php b/database/migrations/2025_01_04_021120_item.php
deleted file mode 100644
index 88fa2f3..0000000
--- a/database/migrations/2025_01_04_021120_item.php
+++ /dev/null
@@ -1,24 +0,0 @@
-<?php
-
-use Illuminate\Database\Migrations\Migration;
-use Illuminate\Database\Schema\Blueprint;
-use Illuminate\Support\Facades\Schema;
-
-return new class extends Migration
-{
- /**
- * Run the migrations.
- */
- public function up(): void
- {
- //
- }
-
- /**
- * Reverse the migrations.
- */
- public function down(): void
- {
- //
- }
-};
diff --git a/database/migrations/2026_02_15_000000_create_visitor_logs_table.php b/database/migrations/2026_02_15_000000_create_visitor_logs_table.php
new file mode 100644
index 0000000..1cffeb5
--- /dev/null
+++ b/database/migrations/2026_02_15_000000_create_visitor_logs_table.php
@@ -0,0 +1,35 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ */
+ public function up(): void
+ {
+ Schema::create('visitor_logs', function (Blueprint $table) {
+ $table->id();
+ $table->string('ip_address', 45)->unique();
+ $table->unsignedInteger('total_requests')->default(0);
+ $table->unsignedInteger('status_200_count')->default(0);
+ $table->unsignedInteger('status_404_count')->default(0);
+ $table->unsignedInteger('status_405_count')->default(0);
+ $table->unsignedInteger('status_500_count')->default(0);
+ $table->timestamp('first_seen_at')->nullable();
+ $table->timestamp('last_seen_at')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('visitor_logs');
+ }
+};
diff --git a/database/migrations/2026_03_05_224025_create_link_tag_table.php b/database/migrations/2026_03_05_224025_create_link_tag_table.php
new file mode 100644
index 0000000..cdec15e
--- /dev/null
+++ b/database/migrations/2026_03_05_224025_create_link_tag_table.php
@@ -0,0 +1,31 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ */
+ public function up(): void
+ {
+ if (!Schema::hasTable('link_tag')) {
+ Schema::create('link_tag', function (Blueprint $table) {
+ $table->integer('link_id')->unsigned()->index();
+ $table->foreign('link_id')->references('id')->on('links')->onDelete('cascade');
+ $table->integer('tag_id')->unsigned()->index();
+ $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
+ });
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('link_tag');
+ }
+};
diff --git a/database/migrations/2026_03_06_171040_fix_links_table_description_nullable_and_fk.php b/database/migrations/2026_03_06_171040_fix_links_table_description_nullable_and_fk.php
new file mode 100644
index 0000000..9787a5d
--- /dev/null
+++ b/database/migrations/2026_03_06_171040_fix_links_table_description_nullable_and_fk.php
@@ -0,0 +1,50 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ */
+ public function up(): void
+ {
+ Schema::table('links', function (Blueprint $table) {
+ // Make description nullable (was NOT NULL)
+ $table->string('description')->nullable()->change();
+
+ // Fix user_id column type to match users.id (bigint unsigned)
+ $table->unsignedBigInteger('user_id')->nullable()->change();
+ });
+
+ // Add proper foreign key constraint if it doesn't exist
+ try {
+ Schema::table('links', function (Blueprint $table) {
+ $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
+ });
+ } catch (\Exception $e) {
+ // FK may already exist
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ try {
+ Schema::table('links', function (Blueprint $table) {
+ $table->dropForeign(['user_id']);
+ });
+ } catch (\Exception $e) {
+ // FK may not exist
+ }
+
+ Schema::table('links', function (Blueprint $table) {
+ $table->string('description')->nullable(false)->change();
+ $table->integer('user_id')->change();
+ });
+ }
+};
diff --git a/database/migrations/2026_03_06_171913_fix_pivot_table_fk_types.php b/database/migrations/2026_03_06_171913_fix_pivot_table_fk_types.php
new file mode 100644
index 0000000..14acf86
--- /dev/null
+++ b/database/migrations/2026_03_06_171913_fix_pivot_table_fk_types.php
@@ -0,0 +1,98 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ */
+ public function up(): void
+ {
+ // Drop the broken quest_tag table (quests table has no real columns)
+ Schema::dropIfExists('quest_tag');
+
+ // Fix FK column types: integer unsigned -> unsignedBigInteger
+ // to match the bigIncrements id() columns on parent tables.
+ // For each pivot table: drop FKs, alter column types, re-add FKs.
+
+ $pivots = [
+ 'link_tag' => [
+ ['col' => 'link_id', 'ref_table' => 'links'],
+ ['col' => 'tag_id', 'ref_table' => 'tags'],
+ ],
+ 'file_tag' => [
+ ['col' => 'file_id', 'ref_table' => 'files'],
+ ['col' => 'tag_id', 'ref_table' => 'tags'],
+ ],
+ 'tag_writing' => [
+ ['col' => 'writing_id', 'ref_table' => 'writings'],
+ ['col' => 'tag_id', 'ref_table' => 'tags'],
+ ],
+ 'role_user' => [
+ ['col' => 'user_id', 'ref_table' => 'users'],
+ ['col' => 'role_id', 'ref_table' => 'roles'],
+ ],
+ ];
+
+ foreach ($pivots as $table => $columns) {
+ if (!Schema::hasTable($table)) {
+ continue;
+ }
+
+ // Drop existing foreign keys
+ Schema::table($table, function (Blueprint $t) use ($columns) {
+ foreach ($columns as $fk) {
+ try {
+ $t->dropForeign([$fk['col']]);
+ } catch (\Exception $e) {
+ // FK may not exist
+ }
+ }
+ });
+
+ // Change column types
+ Schema::table($table, function (Blueprint $t) use ($columns) {
+ foreach ($columns as $fk) {
+ $t->unsignedBigInteger($fk['col'])->change();
+ }
+ });
+
+ // Re-add foreign keys
+ Schema::table($table, function (Blueprint $t) use ($columns) {
+ foreach ($columns as $fk) {
+ $t->foreign($fk['col'])->references('id')->on($fk['ref_table'])->onDelete('cascade');
+ }
+ });
+ }
+
+ // Create the missing tag_quote pivot table
+ if (!Schema::hasTable('tag_quote')) {
+ Schema::create('tag_quote', function (Blueprint $table) {
+ $table->unsignedBigInteger('quote_id')->index();
+ $table->foreign('quote_id')->references('id')->on('quotes')->onDelete('cascade');
+ $table->unsignedBigInteger('tag_id')->index();
+ $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
+ });
+ }
+ }
+
+ public function down(): void
+ {
+ // Drop tag_quote (we created it)
+ Schema::dropIfExists('tag_quote');
+
+ // Recreate quest_tag
+ if (!Schema::hasTable('quest_tag')) {
+ Schema::create('quest_tag', function (Blueprint $table) {
+ $table->unsignedBigInteger('quest_id')->index();
+ $table->unsignedBigInteger('tag_id')->index();
+ });
+ }
+
+ // Reverting column types back to integer unsigned is risky
+ // if IDs exceed integer range, so we leave them as unsignedBigInteger.
+ }
+};
diff --git a/database/migrations/2026_03_06_181900_fix_writings_user_id_fk_type.php b/database/migrations/2026_03_06_181900_fix_writings_user_id_fk_type.php
new file mode 100644
index 0000000..6466781
--- /dev/null
+++ b/database/migrations/2026_03_06_181900_fix_writings_user_id_fk_type.php
@@ -0,0 +1,48 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ */
+ public function up(): void
+ {
+ Schema::table('writings', function (Blueprint $table) {
+ // Drop the existing FK before altering the column type
+ try {
+ $table->dropForeign(['user_id']);
+ } catch (\Exception $e) {
+ // FK may not exist on SQLite or fresh DBs
+ }
+
+ // Fix type mismatch: integer -> unsignedBigInteger (to match users.id bigIncrements)
+ $table->unsignedBigInteger('user_id')->nullable()->change();
+
+ // Re-add the FK constraint
+ try {
+ $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
+ } catch (\Exception $e) {
+ // SQLite ignores FK constraints by default
+ }
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('writings', function (Blueprint $table) {
+ try {
+ $table->dropForeign(['user_id']);
+ } catch (\Exception $e) {}
+
+ $table->integer('user_id')->unsigned()->nullable()->change();
+
+ try {
+ $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
+ } catch (\Exception $e) {}
+ });
+ }
+};
diff --git a/database/migrations/99999_pivots.php b/database/migrations/99999_pivots.php
index edd497f..e0649ff 100644
--- a/database/migrations/99999_pivots.php
+++ b/database/migrations/99999_pivots.php
@@ -12,42 +12,46 @@ return new class extends Migration
*/
public function up(): void
{
- Schema::create('link_tag', function (Blueprint $table) {
- $table->integer('link_id')->unsigned()->index();
- $table->foreign('link_id')->references('id')->on('links')->onDelete('cascade');
- $table->integer('tag_id')->unsigned()->index();
- $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
- });
- Schema::create('file_tag', function (Blueprint $table) {
- $table->integer('file_id')->unsigned()->index();
- $table->foreign('file_id')->references('id')->on('files')->onDelete('cascade');
- $table->integer('tag_id')->unsigned()->index();
- $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
- });
- Schema::create('quest_tag', function (Blueprint $table) {
- $table->integer('quest_id')->unsigned()->index();
- $table->foreign('quest_id')->references('id')->on('quests')->onDelete('cascade');
- $table->integer('tag_id')->unsigned()->index();
- $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
- });
- Schema::create('tag_writing', function (Blueprint $table){
- $table->integer('writing_id')->unsigned()->index();
- $table->foreign('writing_id')->references('id')->on('writings')->onDelete('cascade');
- $table->integer('tag_id')->unsigned()->index();
- $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
- });
- Schema::create('role_user', function (Blueprint $table) {
- $table->integer('user_id')->unsigned()->index();
- $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
- $table->integer('role_id')->unsigned()->index();
- $table->foreign('role_id')->references('id')->on('roles')->onDelete('cascade');
- });
- Schema::create('tag_quote', function (Blueprint $table) {
- $table->integer('quote_id')->unsigned()->index();
- $table->foreign('quote_id')->references('id')->on('quotes')->onDelete('cascade');
- $table->integer('tag_id')->unsigned()->index();
- $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
- });
+ if (!Schema::hasTable('link_tag')) {
+ Schema::create('link_tag', function (Blueprint $table) {
+ $table->unsignedBigInteger('link_id')->index();
+ $table->foreign('link_id')->references('id')->on('links')->onDelete('cascade');
+ $table->unsignedBigInteger('tag_id')->index();
+ $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
+ });
+ }
+ if (!Schema::hasTable('file_tag')) {
+ Schema::create('file_tag', function (Blueprint $table) {
+ $table->unsignedBigInteger('file_id')->index();
+ $table->foreign('file_id')->references('id')->on('files')->onDelete('cascade');
+ $table->unsignedBigInteger('tag_id')->index();
+ $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
+ });
+ }
+ if (!Schema::hasTable('tag_writing')) {
+ Schema::create('tag_writing', function (Blueprint $table) {
+ $table->unsignedBigInteger('writing_id')->index();
+ $table->foreign('writing_id')->references('id')->on('writings')->onDelete('cascade');
+ $table->unsignedBigInteger('tag_id')->index();
+ $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
+ });
+ }
+ if (!Schema::hasTable('role_user')) {
+ Schema::create('role_user', function (Blueprint $table) {
+ $table->unsignedBigInteger('user_id')->index();
+ $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
+ $table->unsignedBigInteger('role_id')->index();
+ $table->foreign('role_id')->references('id')->on('roles')->onDelete('cascade');
+ });
+ }
+ if (!Schema::hasTable('tag_quote')) {
+ Schema::create('tag_quote', function (Blueprint $table) {
+ $table->unsignedBigInteger('quote_id')->index();
+ $table->foreign('quote_id')->references('id')->on('quotes')->onDelete('cascade');
+ $table->unsignedBigInteger('tag_id')->index();
+ $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
+ });
+ }
}
/**
@@ -57,9 +61,8 @@ return new class extends Migration
{
Schema::dropIfExists('link_tag');
Schema::dropIfExists('file_tag');
- Schema::dropIfExists('quest_tag');
Schema::dropIfExists('tag_writing');
+ Schema::dropIfExists('role_user');
Schema::dropIfExists('tag_quote');
- //Schema::dropIfExists('role_user');
}
};
diff --git a/docs/hype.md b/docs/hype.md
index 40680b6..087d712 100644
--- a/docs/hype.md
+++ b/docs/hype.md
@@ -14,11 +14,8 @@
- it will force me to communicate about technology, which i am decent at, better than most people at, and what the world needs more of i think.
## broken-down tasks
-- make the intro header thing
- get the user accounts working properly
- make a nice user dashboard. shows all relevant data-objects associated with the user
- -
- -
- finish the link database (add new links). come up with a better UI.
- make a good database-searching functionality
- implement some more htmx
diff --git a/package.json b/package.json
index 08953d8..e280a32 100755
--- a/package.json
+++ b/package.json
@@ -9,7 +9,6 @@
"@tailwindcss/forms": "^0.5.2",
"alpinejs": "^3.4.2",
"autoprefixer": "^10.4.2",
- "axios": "^1.7.4",
"concurrently": "^9.0.1",
"laravel-echo": "^1.17.1",
"laravel-vite-plugin": "^1.0",
@@ -17,5 +16,10 @@
"pusher-js": "^8.4.0-rc2",
"tailwindcss": "^3.1.0",
"vite": "^5.0"
+ },
+ "dependencies": {
+ "htmx.org": "^2.0.1",
+ "jquery": "^3.7.1",
+ "marked": "^17.0.4"
}
}
diff --git a/phpunit.xml b/phpunit.xml
index 506b9a3..61c031c 100755
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -22,8 +22,8 @@
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
- <!-- <env name="DB_CONNECTION" value="sqlite"/> -->
- <!-- <env name="DB_DATABASE" value=":memory:"/> -->
+ <env name="DB_CONNECTION" value="sqlite"/>
+ <env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/>
diff --git a/public/css/home.css b/public/css/home.css
index 79dc5b2..ebbbb29 100644
--- a/public/css/home.css
+++ b/public/css/home.css
@@ -17,8 +17,7 @@ header {
text-align: center;
}
main {
- max-width: 800px;
- margin: 2rem auto;
+ margin: .2rem 8rem .5rem 8rem;
padding: 1rem;
background-color: #cecece;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
@@ -42,11 +41,12 @@ section h3 {
color: #3f6d87;
text-decoration: none;
}
-section a {
+a {
color: #3f6d87;
text-decoration: none;
+ padding: .5rem;
}
-section li {
+li {
list-style-type: none;
margin: 1rem 1rem;
}
diff --git a/public/css/writings.css b/public/css/writings.css
index 7e23d51..73f89fa 100644
--- a/public/css/writings.css
+++ b/public/css/writings.css
@@ -1,7 +1,6 @@
/* Writing Creation Page Styles */
main .writing-create {
width: 100%;
- max-width: 2000px;
}
.writing-create {
diff --git a/public/js/main.js b/public/js/main.js
index 931cfb2..b7673cc 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -35,7 +35,7 @@ function prepareFileInput(){
console.log(f);
}
//TODO validation
- domElems.buttonFileUpload.enabled = true;
+ //domElems.buttonFileUpload.enabled = true;
}
//APP START HERE
@@ -107,14 +107,14 @@ async function getServerEnvVars(){
function initDOM(){
// temp disabled $('#fileupload')[0].hidden = false;
domElems.numConnected = $('#numFriendsConnected')[0];
- domElems.inputFile = $('#f')[0];
+ //domElems.inputFile = $('#f')[0];
domElems.debugInfo = $('#infolog')[0];
if (domElems.debugInfo) {
domElems.debugInfo.style.display = env.MODE == 'local' ? 'block' : 'none';
}
domElems.fileUploadResult = $('#fileupload_result')[0];
- domElems.inputFile.onchange = prepareFileInput;
- domElems.buttonFileUpload = $('#button_fileupload')[0];
+ //domElems.inputFile.onchange = prepareFileInput;
+ //domElems.buttonFileUpload = $('#button_fileupload')[0];
domElems.body = $('body')[0];
domElems.cnv = $('#bg')[0];
domElems.statusWS = $('#statusWS')[0];
@@ -384,4 +384,4 @@ function log(msg, lvl=1){
domElems.debugInfo.innerHTML = msg; //TODO running log + timestamp
}
console.log(msg);
-} \ No newline at end of file
+}
diff --git a/public/js/writing_create.js b/public/js/writing_create.js
index e1c88c0..dc737f8 100644
--- a/public/js/writing_create.js
+++ b/public/js/writing_create.js
@@ -1,33 +1,25 @@
-let contentPreview;
-let inputText;
let dom = {};
let editorHistory = [];
-
-$(function() {
-
- if (typeof marked == 'undefined'){
+document.addEventListener('DOMContentLoaded', () => {
+ if (typeof marked === 'undefined') {
console.error("marked lib not loaded");
return;
- } else {
- console.log("marked loaded");
- console.log(marked);
}
+
initDOM();
-
- marked.setOptions({
- breaks: true,
- sanitize: true
- })
+ marked.use({ breaks: true });
- dom.inputText.oninput = ()=>{
+ dom.inputText.addEventListener('input', () => {
dom.contentPreview.innerHTML = marked.parse(dom.inputText.value);
- };
+ });
- let restoredContent = localStorage.getItem('writing_draft_content');
- if (restoredContent != null){
- if (localStorage.getItem('writing_draft_expiration') < Date.now()){
+ // Restore autosaved draft
+ const restoredContent = localStorage.getItem('writing_draft_content');
+ if (restoredContent !== null) {
+ const expiration = parseInt(localStorage.getItem('writing_draft_expiration') || '0');
+ if (expiration < Date.now()) {
localStorage.removeItem('writing_draft_content');
localStorage.removeItem('writing_draft_expiration');
} else {
@@ -36,47 +28,43 @@ $(function() {
}
}
- //periodically save the contents to client to prevent data loss
- const intervalId = setInterval(() => {
- const content = dom.inputText.value;
- localStorage.setItem('writing_draft_content', content);
- localStorage.setItem('writing_draft_expiration', Date.now() + 1000*3600*96);
+ // Periodically save contents to localStorage to prevent data loss
+ setInterval(() => {
+ localStorage.setItem('writing_draft_content', dom.inputText.value);
+ localStorage.setItem('writing_draft_expiration', Date.now() + 1000 * 3600 * 96);
}, 5000);
+ // Clear draft on successful submit
+ document.getElementById('writing-form')?.addEventListener('submit', () => {
+ localStorage.removeItem('writing_draft_content');
+ localStorage.removeItem('writing_draft_expiration');
+ });
});
-function initDOM(){
- dom.inputText = $('#input_content')[0];
- dom.contentPreview = $('#content_preview')[0];
- dom.findStr = $('#input_findstr')[0];
- dom.replaceStr = $('#input_replacestr')[0];
- dom.btnReplace = $('#button_replace')[0];
- dom.btnUndo = $('#button_undo')[0];
- console.log(dom);
- dom.btnReplace.onclick = performReplace;
- dom.btnUndo.onclick = ()=>{
- if (editorHistory.length > 0){
+function initDOM() {
+ dom.inputText = document.getElementById('input_content');
+ dom.contentPreview = document.getElementById('content_preview');
+ dom.findStr = document.getElementById('input_findstr');
+ dom.replaceStr = document.getElementById('input_replacestr');
+ dom.btnReplace = document.getElementById('button_replace');
+ dom.btnUndo = document.getElementById('button_undo');
+ dom.toggleMultiline = document.getElementById('input_multiline');
+
+ dom.btnReplace.addEventListener('click', performReplace);
+ dom.btnUndo.addEventListener('click', () => {
+ if (editorHistory.length > 0) {
dom.inputText.value = editorHistory.pop();
dom.contentPreview.innerHTML = marked.parse(dom.inputText.value);
}
- };
- dom.toggleMultiline = $('#input_multiline')[0];
+ });
}
-function performReplace(){
-
-
- // Save current state to history for undo
- editorHistory.push(dom.inputText.value);
-
- // Perform the replacement
- const regex = new RegExp(dom.findStr.value, dom.toggleMultiline.checked ? 'gm' : 'g');
- dom.inputText.value = dom.inputText.value.replace(regex, dom.replaceStr.value);
- dom.contentPreview.innerHTML = marked.parse(dom.inputText.value);
-
-
+function performReplace() {
+ // Save current state for undo
editorHistory.push(dom.inputText.value);
+ const flags = dom.toggleMultiline.checked ? 'gm' : 'g';
+ const regex = new RegExp(dom.findStr.value, flags);
dom.inputText.value = dom.inputText.value.replace(regex, dom.replaceStr.value);
dom.contentPreview.innerHTML = marked.parse(dom.inputText.value);
}; \ No newline at end of file
diff --git a/resources/js/app.js b/resources/js/app.js
new file mode 100644
index 0000000..f0fa127
--- /dev/null
+++ b/resources/js/app.js
@@ -0,0 +1,15 @@
+import htmx from 'htmx.org';
+import axios from 'axios';
+import jQuery from 'jquery';
+
+// Expose globally so inline scripts and Blade templates can use them
+window.htmx = htmx;
+window.axios = axios;
+window.$ = jQuery;
+window.jQuery = jQuery;
+
+// Set axios CSRF header from meta tag
+const token = document.querySelector('meta[name="csrf-token"]');
+if (token) {
+ axios.defaults.headers.common['X-CSRF-TOKEN'] = token.getAttribute('content');
+}
diff --git a/resources/js/writing_create.js b/resources/js/writing_create.js
new file mode 100644
index 0000000..cd44a66
--- /dev/null
+++ b/resources/js/writing_create.js
@@ -0,0 +1,67 @@
+import $ from 'jquery';
+import { marked } from 'marked';
+
+marked.use({ breaks: true });
+
+const dom = {};
+const editorHistory = [];
+
+$(document).ready(() => {
+ initDOM();
+
+ dom.inputText.addEventListener('input', () => {
+ dom.contentPreview.innerHTML = marked.parse(dom.inputText.value);
+ });
+
+ // Restore autosaved draft
+ const restoredContent = localStorage.getItem('writing_draft_content');
+ if (restoredContent !== null) {
+ const expiration = parseInt(localStorage.getItem('writing_draft_expiration') || '0');
+ if (expiration < Date.now()) {
+ localStorage.removeItem('writing_draft_content');
+ localStorage.removeItem('writing_draft_expiration');
+ } else {
+ dom.inputText.value = restoredContent;
+ dom.contentPreview.innerHTML = marked.parse(dom.inputText.value);
+ }
+ }
+
+ // Periodically save contents to localStorage to prevent data loss
+ setInterval(() => {
+ localStorage.setItem('writing_draft_content', dom.inputText.value);
+ localStorage.setItem('writing_draft_expiration', String(Date.now() + 1000 * 3600 * 96));
+ }, 5000);
+
+ // Clear draft on successful submit
+ document.getElementById('writing-form')?.addEventListener('submit', () => {
+ localStorage.removeItem('writing_draft_content');
+ localStorage.removeItem('writing_draft_expiration');
+ });
+});
+
+function initDOM() {
+ dom.inputText = document.getElementById('input_content');
+ dom.contentPreview = document.getElementById('content_preview');
+ dom.findStr = document.getElementById('input_findstr');
+ dom.replaceStr = document.getElementById('input_replacestr');
+ dom.btnReplace = document.getElementById('button_replace');
+ dom.btnUndo = document.getElementById('button_undo');
+ dom.toggleMultiline = document.getElementById('input_multiline');
+
+ dom.btnReplace.addEventListener('click', performReplace);
+ dom.btnUndo.addEventListener('click', () => {
+ if (editorHistory.length > 0) {
+ dom.inputText.value = editorHistory.pop();
+ dom.contentPreview.innerHTML = marked.parse(dom.inputText.value);
+ }
+ });
+}
+
+function performReplace() {
+ editorHistory.push(dom.inputText.value);
+
+ const flags = dom.toggleMultiline.checked ? 'gm' : 'g';
+ const regex = new RegExp(dom.findStr.value, flags);
+ dom.inputText.value = dom.inputText.value.replace(regex, dom.replaceStr.value);
+ dom.contentPreview.innerHTML = marked.parse(dom.inputText.value);
+}
diff --git a/resources/views/admin.blade.php b/resources/views/admin.blade.php
new file mode 100644
index 0000000..bf8db6f
--- /dev/null
+++ b/resources/views/admin.blade.php
@@ -0,0 +1,268 @@
+@extends('template')
+
+@section('head')
+@endsection
+
+@section('content')
+<h1>Admin</h1>
+
+@if(session('success'))
+ <div class="flash-success">{{ session('success') }}</div>
+@endif
+
+<div class="admin-layout">
+ <nav class="admin-sidebar">
+ <a href="#visitors" class="admin-nav active" data-section="visitors">Visitors</a>
+ <a href="#files" class="admin-nav" data-section="files">Files</a>
+ <a href="#links" class="admin-nav" data-section="links">Links</a>
+ </nav>
+
+ <div class="admin-content">
+ {{-- ==================== VISITORS ==================== --}}
+ <div id="section-visitors" class="admin-section active">
+ <h2>Visitor Logs</h2>
+
+ <div class="summary-row">
+ <div class="summary-stat">
+ <div class="num">{{ $visitorSummary['unique_ips'] }}</div>
+ <div class="lbl">Unique IPs</div>
+ </div>
+ <div class="summary-stat">
+ <div class="num">{{ number_format($visitorSummary['total_requests']) }}</div>
+ <div class="lbl">Total Requests</div>
+ </div>
+ <div class="summary-stat">
+ <div class="num">{{ number_format($visitorSummary['total_200']) }}</div>
+ <div class="lbl">200</div>
+ </div>
+ <div class="summary-stat">
+ <div class="num">{{ number_format($visitorSummary['total_404']) }}</div>
+ <div class="lbl">404</div>
+ </div>
+ <div class="summary-stat">
+ <div class="num">{{ number_format($visitorSummary['total_405']) }}</div>
+ <div class="lbl">405</div>
+ </div>
+ <div class="summary-stat">
+ <div class="num">{{ number_format($visitorSummary['total_500']) }}</div>
+ <div class="lbl">500</div>
+ </div>
+ </div>
+
+ <table class="admin-table">
+ <thead>
+ <tr>
+ <th>IP Address</th>
+ <th>Requests</th>
+ <th>200</th>
+ <th>404</th>
+ <th>405</th>
+ <th>500</th>
+ <th>First Seen</th>
+ <th>Last Seen</th>
+ </tr>
+ </thead>
+ <tbody>
+ @forelse($visitors as $v)
+ <tr>
+ <td>{{ $v->ip_address }}</td>
+ <td>{{ number_format($v->total_requests) }}</td>
+ <td>{{ $v->status_200_count }}</td>
+ <td>{{ $v->status_404_count }}</td>
+ <td>{{ $v->status_405_count }}</td>
+ <td>{{ $v->status_500_count }}</td>
+ <td>{{ $v->first_seen_at?->format('Y-m-d H:i') }}</td>
+ <td>{{ $v->last_seen_at?->diffForHumans() }}</td>
+ </tr>
+ @empty
+ <tr><td colspan="8" style="text-align:center;color:#999;">No visitor data yet</td></tr>
+ @endforelse
+ </tbody>
+ </table>
+ </div>
+
+ {{-- ==================== FILES ==================== --}}
+ <div id="section-files" class="admin-section">
+ <h2>Files</h2>
+
+ <div class="admin-form">
+ <h3>Upload Files</h3>
+ <form action="/f" method="POST" enctype="multipart/form-data">
+ @csrf
+ <input type="hidden" name="response_format" value="html">
+ <input type="hidden" name="_redirect" value="/admin#files">
+ <div class="form-row">
+ <div class="form-group">
+ <label for="f">Select files</label>
+ <input type="file" name="f[]" id="f" multiple>
+ </div>
+ <div class="form-group">
+ <label for="upload-tags">Tags (comma-separated)</label>
+ <input type="text" name="tags" id="upload-tags" placeholder="e.g. photo, doc">
+ </div>
+ <button type="submit" class="btn btn-primary">Upload</button>
+ </div>
+ </form>
+ </div>
+
+ <table class="admin-table">
+ <thead>
+ <tr>
+ <th>Filename</th>
+ <th>Path</th>
+ <th>Size</th>
+ <th>Uploaded</th>
+ <th>Actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ @forelse($files as $file)
+ <tr>
+ <td>{{ $file->filename }}</td>
+ <td>{{ $file->path }}</td>
+ <td>{{ $file->size ? round($file->size / 1024, 1) . ' KB' : '—' }}</td>
+ <td>{{ $file->created_at?->format('Y-m-d') }}</td>
+ <td>
+ <form action="/f/{{ $file->id }}" method="POST" style="display:inline"
+ onsubmit="return confirm('Delete this file?')">
+ @csrf
+ @method('DELETE')
+ <input type="hidden" name="_redirect" value="/admin#files">
+ <button type="submit" class="btn btn-danger btn-sm">Delete</button>
+ </form>
+ </td>
+ </tr>
+ @empty
+ <tr><td colspan="5" style="text-align:center;color:#999;">No files</td></tr>
+ @endforelse
+ </tbody>
+ </table>
+ </div>
+
+ {{-- ==================== LINKS ==================== --}}
+ <div id="section-links" class="admin-section">
+ <h2>Links</h2>
+
+ <div class="admin-form">
+ <h3>Add Link</h3>
+ <form action="/l" method="POST">
+ @csrf
+ <input type="hidden" name="_redirect" value="/admin#links">
+ <div class="form-row">
+ <div class="form-group">
+ <label for="link-label">Label</label>
+ <input type="text" name="label" id="link-label" required>
+ </div>
+ <div class="form-group">
+ <label for="link-url">URL</label>
+ <input type="text" name="url" id="link-url" required>
+ </div>
+ <div class="form-group">
+ <label for="link-desc">Description</label>
+ <input type="text" name="description" id="link-desc">
+ </div>
+ <button type="submit" class="btn btn-primary">Add</button>
+ </div>
+ </form>
+ </div>
+
+ <table class="admin-table">
+ <thead>
+ <tr>
+ <th>Label</th>
+ <th>URL</th>
+ <th>Description</th>
+ <th>Actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ @forelse($links as $link)
+ <tr id="link-row-{{ $link->id }}">
+ <td class="link-display">{{ $link->label }}</td>
+ <td class="link-display"><a href="{{ $link->url }}" target="_blank">{{ Str::limit($link->url, 50) }}</a></td>
+ <td class="link-display">{{ $link->description }}</td>
+ <td>
+ <button class="btn btn-primary btn-sm" onclick="editLink({{ $link->id }}, this)">Edit</button>
+ <form action="/l/{{ $link->id }}" method="POST" style="display:inline"
+ onsubmit="return confirm('Delete this link?')">
+ @csrf
+ @method('DELETE')
+ <input type="hidden" name="_redirect" value="/admin#links">
+ <button type="submit" class="btn btn-danger btn-sm">Delete</button>
+ </form>
+ </td>
+ </tr>
+ @empty
+ <tr><td colspan="4" style="text-align:center;color:#999;">No links</td></tr>
+ @endforelse
+ </tbody>
+ </table>
+ </div>
+ </div>
+</div>
+@endsection
+
+@section('scripts')
+<script>
+// Tab navigation
+document.querySelectorAll('.admin-nav').forEach(link => {
+ link.addEventListener('click', function(e) {
+ e.preventDefault();
+ const section = this.dataset.section;
+
+ document.querySelectorAll('.admin-nav').forEach(n => n.classList.remove('active'));
+ document.querySelectorAll('.admin-section').forEach(s => s.classList.remove('active'));
+
+ this.classList.add('active');
+ document.getElementById('section-' + section).classList.add('active');
+
+ history.replaceState(null, '', '#' + section);
+ });
+});
+
+// Restore tab from hash
+window.addEventListener('DOMContentLoaded', function() {
+ const hash = window.location.hash.replace('#', '');
+ if (hash) {
+ const navLink = document.querySelector(`.admin-nav[data-section="${hash}"]`);
+ if (navLink) navLink.click();
+ }
+});
+
+// Inline link editing
+function editLink(id, btn) {
+ const row = document.getElementById('link-row-' + id);
+ const cells = row.querySelectorAll('.link-display');
+ const label = cells[0].textContent.trim();
+ const url = cells[1].textContent.trim();
+ const desc = cells[2].textContent.trim();
+
+ cells[0].innerHTML = `<input name="label" value="${label}">`;
+ cells[1].innerHTML = `<input name="url" value="${url}">`;
+ cells[2].innerHTML = `<input name="description" value="${desc}">`;
+
+ btn.textContent = 'Save';
+ btn.onclick = function() { saveLink(id, row, btn); };
+}
+
+function saveLink(id, row, btn) {
+ const data = {
+ label: row.querySelector('input[name="label"]').value,
+ url: row.querySelector('input[name="url"]').value,
+ description: row.querySelector('input[name="description"]').value,
+ _token: '{{ csrf_token() }}',
+ _method: 'PUT',
+ _redirect: '/admin#links'
+ };
+
+ axios.post('/l/' + id, data)
+ .then(function() {
+ window.location.href = '/admin#links';
+ window.location.reload();
+ })
+ .catch(function(err) {
+ alert('Error saving link: ' + (err.response?.data?.message || err.message));
+ });
+}
+</script>
+@endsection
diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php
index 476b49c..e460bde 100644
--- a/resources/views/dashboard.blade.php
+++ b/resources/views/dashboard.blade.php
@@ -163,34 +163,6 @@
<main>
<h1>Dashboard</h1>
- @php
- $user = Auth::user();
-
- // Storage stats
- $usedStorage = $user->getStorageUsed();
- $totalStorage = $user->storage_quota;
- $storagePercent = $totalStorage > 0 ? min(100, round(($usedStorage / $totalStorage) * 100)) : 0;
-
- // File stats
- $fileCount = $user->files()->count();
- $recentFiles = $user->files()->orderBy('created_at', 'desc')->take(3)->get();
-
- // Writing stats
- $writingCount = $user->writings()->count();
- $recentWritings = $user->writings()->orderBy('created_at', 'desc')->take(3)->get();
-
- // Quest stats (if implemented)
- $questCount = method_exists($user, 'quests') ? $user->quests()->count() : 0;
- $activeQuests = method_exists($user, 'quests') ? $user->quests()
- ->wherePivot('status', 'in_progress')
- ->orderBy('created_at', 'desc')
- ->take(3)
- ->get() : collect();
-
- // Inventory stats (placeholder)
- $inventoryCount = 0; // Update when inventory is implemented
- @endphp
-
<!-- Profile Overview -->
<div class="dashboard-card" style="margin-bottom: 20px;">
<div class="avatar-container">
@@ -216,14 +188,6 @@
<div class="stat-number">{{ $writingCount }}</div>
<div class="stat-label">Writings</div>
</div>
- <div class="stat-item">
- <div class="stat-number">{{ $questCount }}</div>
- <div class="stat-label">Quests</div>
- </div>
- <div class="stat-item">
- <div class="stat-number">{{ $inventoryCount }}</div>
- <div class="stat-label">Items</div>
- </div>
</div>
<div class="dashboard-container">
@@ -242,27 +206,8 @@
</div>
<h3>Recent Files</h3>
- @if($recentFiles->count() > 0)
- <ul class="item-list">
- @foreach($recentFiles as $file)
- <li>
- <div style="display: flex; justify-content: space-between;">
- <div>
- <a href="/f/{{ $file->filename }}">{{ Str::limit($file->filename, 25) }}</a>
- <div style="font-size: 0.8rem; color: #666;">
- {{ round($file->size / 1024 / 1024, 2) }} MB • {{ $file->created_at->diffForHumans() }}
- </div>
- </div>
- <div>
- <a href="?file={{ $file->path }}" title="Download">⬇️</a>
- </div>
- </div>
- </li>
- @endforeach
- </ul>
- @else
- <div class="empty-state">No files uploaded yet</div>
- @endif
+
+ <div class="empty-state">No files uploaded yet</div>
<div class="dashboard-card-footer">
<a href="/f">Manage Files →</a>
@@ -298,81 +243,12 @@
<a href="{{ route('w.index') }}">All Writings →</a>
</div>
</div>
-
- <!-- Quests Card -->
- <div class="dashboard-card">
- <h2>Quests</h2>
-
- @if(method_exists($user, 'quests') && $activeQuests->count() > 0)
- <ul class="item-list">
- @foreach($activeQuests as $quest)
- <li>
- <div>
- <a href="{{ route('quests.show', $quest->id) }}">{{ Str::limit($quest->title, 35) }}</a>
- <div style="font-size: 0.8rem; color: #666;">
- Progress: {{ $quest->pivot->progress }}%
- </div>
- <div class="progress-bar" style="margin: 5px 0; height: 6px;">
- <div class="progress-fill" style="width: {{ $quest->pivot->progress }}%; background-color: #00a86b;"></div>
- </div>
- <div style="font-size: 0.8rem; color: #666;">
- <span class="badge badge-{{ $quest->difficulty == 'easy' ? 'success' : ($quest->difficulty == 'hard' ? 'warning' : 'primary') }}">
- {{ ucfirst($quest->difficulty) }}
- </span>
- {{ $quest->reward_points }} points
- </div>
- </div>
- </li>
- @endforeach
- </ul>
- @else
- <div class="empty-state">No active quests</div>
- @endif
-
- <div class="dashboard-card-footer">
- @if(method_exists($user, 'quests'))
- <a href="{{ route('quests.index') }}">View Quests →</a>
- @else
- <span style="color: #999;">Coming soon</span>
- @endif
- </div>
- </div>
-
- <!-- Inventory Card (Placeholder) -->
- <div class="dashboard-card">
- <h2>Inventory</h2>
-
- <div class="empty-state">Inventory system coming soon</div>
-
- <div class="dashboard-card-footer">
- <span style="color: #999;">Under development</span>
- </div>
- </div>
</div>
<!-- Tags Used -->
<div class="dashboard-card">
<h2>Your Tags</h2>
-
- @php
- // Get all tags from user's content
- $fileTags = $user->files()->with('tags')->get()->pluck('tags')->flatten();
- $writingTags = method_exists($user->writings(), 'with') ?
- $user->writings()->with('tags')->get()->pluck('tags')->flatten() :
- collect();
-
- $allTags = $fileTags->merge($writingTags)->unique('id');
- @endphp
-
- @if($allTags->count() > 0)
- <div class="tag-list">
- @foreach($allTags as $tag)
- <span class="tag">{{ $tag->label }}</span>
- @endforeach
- </div>
- @else
- <div class="empty-state">No tags used yet</div>
- @endif
+ TODO
</div>
</main>
@endsection \ No newline at end of file
diff --git a/resources/views/home.blade.php b/resources/views/home.blade.php
index f3f40b9..c993212 100755
--- a/resources/views/home.blade.php
+++ b/resources/views/home.blade.php
@@ -65,13 +65,12 @@ UNDER CONSTRUCTION; APOLOGIES FOR LACKING FUNCTIONALITY.
<section class = "widget" id = "dunechapterexcerpts" >
<h4>Dune pre-chapter quotes: </h4>
- <p class="light">(Some of them were improperly parsed from the source material and are missing content. I need to fix that)</p>
<div id = "quote" hx-get = "/dq" hx-trigger = "load,click" hx-swap = "innerHTML">
<h4>random dune pre-chapter quote goes here (under construction)</h4>
</div>
</section>
<footer>
- <p>Last updated February 14th, 2026</p>
+ <p>Last updated March 6th, 2026</p>
</footer>
@endsection
</html>
diff --git a/resources/views/links/create.blade.php b/resources/views/links/create.blade.php
new file mode 100644
index 0000000..3437098
--- /dev/null
+++ b/resources/views/links/create.blade.php
@@ -0,0 +1,71 @@
+@extends('template')
+
+@section('content')
+<main>
+ <h1>Add Link</h1>
+
+ @if ($errors->any())
+ <div style="color: red; margin-bottom: 1rem;">
+ <ul>
+ @foreach ($errors->all() as $error)
+ <li>{{ $error }}</li>
+ @endforeach
+ </ul>
+ </div>
+ @endif
+
+ @if (session('success'))
+ <div style="color: green; margin-bottom: 1rem;">
+ {{ session('success') }}
+ </div>
+ @endif
+
+ {{-- Single link form --}}
+ <section>
+ <h2>Add a Single Link</h2>
+ <form method="POST" action="{{ route('l.store') }}">
+ @csrf
+ <div style="margin-bottom: 0.5rem;">
+ <label for="label">Label</label><br>
+ <input type="text" name="label" id="label" value="{{ old('label') }}" required minlength="2" maxlength="255">
+ </div>
+ <div style="margin-bottom: 0.5rem;">
+ <label for="url">URL</label><br>
+ <input type="url" name="url" id="url" value="{{ old('url') }}" required minlength="3">
+ </div>
+ <div style="margin-bottom: 0.5rem;">
+ <label for="description">Description</label><br>
+ <textarea name="description" id="description" rows="3">{{ old('description') }}</textarea>
+ </div>
+ <button type="submit">Add Link</button>
+ </form>
+ </section>
+
+ {{-- JSON import form — admin only --}}
+ @if(Auth::check() && Auth::user()->isAdmin())
+ <section style="margin-top: 2rem; border-top: 1px solid #ccc; padding-top: 1rem;">
+ <h2>Bulk Import from JSON</h2>
+ <p>Upload a JSON file containing an array of link objects. Each object should have at least a <code>url</code> field. Optional fields: <code>label</code>, <code>description</code>, <code>tags</code> (array of strings). Maximum 500 entries per file. Duplicate URLs will be skipped.</p>
+ <details style="margin-bottom: 1rem;">
+ <summary>Example JSON format</summary>
+ <pre>[
+ {
+ "url": "https://example.com",
+ "label": "Example Site",
+ "tags": ["web", "reference"],
+ "description": "An example website."
+ }
+]</pre>
+ </details>
+ <form method="POST" action="{{ route('l.import') }}" enctype="multipart/form-data">
+ @csrf
+ <div style="margin-bottom: 0.5rem;">
+ <label for="json_file">JSON File</label><br>
+ <input type="file" name="json_file" id="json_file" accept=".json,.txt" required>
+ </div>
+ <button type="submit">Import Links</button>
+ </form>
+ </section>
+ @endif
+</main>
+@endsection
diff --git a/resources/views/links/index.blade.php b/resources/views/links/index.blade.php
index 4ffca7b..89ee25f 100644
--- a/resources/views/links/index.blade.php
+++ b/resources/views/links/index.blade.php
@@ -10,10 +10,10 @@
<div class="grid">
@foreach ($links as $link)
<article class="">
- <a href="{{ $link->url }}" class="">
<h2 class="">
{{ $link->label }}
</h2>
+ <a href="{{ $link->url }}" >
{{ $link->url }}
@@ -26,4 +26,4 @@
</div>
</main>
-@endsection \ No newline at end of file
+@endsection
diff --git a/resources/views/links/show.blade.php b/resources/views/links/show.blade.php
index c70f459..d8a56e6 100644
--- a/resources/views/links/show.blade.php
+++ b/resources/views/links/show.blade.php
@@ -1,7 +1,6 @@
@extends('template')
@section('head')
-<link rel="stylesheet" href="/css/style.css">
@endsection
@section('content')
diff --git a/resources/views/marked.blade.php b/resources/views/marked.blade.php
index 8208ac7..f1deefc 100644
--- a/resources/views/marked.blade.php
+++ b/resources/views/marked.blade.php
@@ -7,10 +7,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!--<link rel="stylesheet" type="text/css" href="static/reset.css">-->
<link rel="stylesheet" type="text/css" href="style.css">
- <script src="https://unpkg.com/htmx.org@2.0.1"></script>
- <script src = "https://code.jquery.com/jquery-3.7.1.min.js"></script>
+ @vite('resources/js/app.js')
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
- @vite('resources/js/marked.js')
+ <script src="/js/marked.js"></script>
<!--<base href = "http://192.168.4.32:9002/">-->
<!--<base href = "http://localhost:9002/">-->
<title>markup playground</title>
diff --git a/resources/views/template.blade.php b/resources/views/template.blade.php
index 39a002a..5d41e61 100755
--- a/resources/views/template.blade.php
+++ b/resources/views/template.blade.php
@@ -4,7 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TG</title>
- <link rel="stylesheet" href="css/home.css">
+ <meta name="csrf-token" content="{{ csrf_token() }}">
+ <link rel="stylesheet" href="/css/home.css">
@yield('head')
</head>
<body>
@@ -19,8 +20,8 @@
<a href = "/">Home</a>
<a href = "/f">Files</a>
<a href = "/l">Resources</a>
- <a href = "/notes">Notes</a>
- <small><a id = "buttonSettings">Settings</a></small>
+ <!--<a href = "/notes">Notes</a>-->
+ <!--<small><a id = "buttonSettings">Settings</a></small>-->
<!-- login ? -->
</nav>
<center>- - -</center>
@@ -29,9 +30,7 @@
<footer>
</footer>
</main>
+ @vite('resources/js/app.js')
+ @yield('scripts')
</body>
-<script src="https://unpkg.com/htmx.org@2.0.1"></script>
-<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
-<script src = "https://code.jquery.com/jquery-3.7.1.min.js"></script>
-@yield('scripts')
-</html> \ No newline at end of file
+</html>
diff --git a/resources/views/writings/create.blade.php b/resources/views/writings/create.blade.php
index 54b5deb..c095b5a 100644
--- a/resources/views/writings/create.blade.php
+++ b/resources/views/writings/create.blade.php
@@ -2,10 +2,8 @@
@section('head')
<meta charset="UTF-8">
-<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
-<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
-<script type = "module" src = "/js/writing_create.js"></script>
-<link rel="stylesheet" href="/css/style.css">
+@vite('resources/js/writing_create.js')
+<link rel="stylesheet" href="/css/home.css">
<link rel="stylesheet" href="/css/writings.css">
@endsection
diff --git a/resources/views/writings/edit.blade.php b/resources/views/writings/edit.blade.php
index ec51d68..e442b5d 100644
--- a/resources/views/writings/edit.blade.php
+++ b/resources/views/writings/edit.blade.php
@@ -8,7 +8,6 @@
@section('content')
<main>
- @if (Auth::user() != null)
<div class="container">
<h1>Edit Writing</h1>
@@ -40,8 +39,5 @@
<a href="{{ route('w.index') }}" class="btn btn-secondary">Cancel</a>
</form>
</div>
- @else
- <section><h3>You must be <a href = "/login">logged in</a> to edit a writing</h3></section>
- @endif
</main>
@endsection
diff --git a/resources/views/writings/show.blade.php b/resources/views/writings/show.blade.php
index ab25974..4b191ad 100644
--- a/resources/views/writings/show.blade.php
+++ b/resources/views/writings/show.blade.php
@@ -16,20 +16,15 @@
<header class="writing-header">
<h1>{{ $writing->title }}</h1>
<div class="writing-meta">
- <span class="author">By {{ $writing->hasUser() ? $writing->user()->get()[0]->name : 'anonymous' }}</span>
+ <span class="author">By {{ $writing->user?->name ?? 'anonymous' }}</span>
</div>
</header>
<div class="writing-content" id="content">
[ loading... ]
</div>
- <script type = "text/javascript">
- let contentMarkup = {{ Js::from($writing->content) }};
- console.log(contentMarkup);
- $('#content')[0].innerHTML = marked.parse(contentMarkup);
- </script>
- @if($writing->hasUser() && Auth::id() === $writing->user_id)
+ @can('update', $writing)
<div class="writing-actions">
<a href="{{ route('w.edit', $writing) }}" class="edit-btn">Edit</a>
<form method="POST" action="{{ route('w.destroy', $writing) }}" class="delete-form">
@@ -38,7 +33,13 @@
<button type="submit" class="delete-btn" onclick="return confirm('Are you sure you want to delete this writing?')">Delete</button>
</form>
</div>
- @endif
+ @endcan
</article>
</main>
@endsection
+
+@section('scripts')
+<script type="text/javascript">
+ document.getElementById('content').innerHTML = marked.parse({{ Js::from($writing->content) }});
+</script>
+@endsection
diff --git a/routes/web.php b/routes/web.php
index e32edfa..e2cf097 100755
--- a/routes/web.php
+++ b/routes/web.php
@@ -1,71 +1,49 @@
<?php
use Illuminate\Support\Facades\Route;
+use Illuminate\Support\Facades\Storage;
use App\Http\Controllers\SiteController;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\WritingController;
use App\Http\Controllers\LinkController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\PresenceController;
+use App\Http\Controllers\AdminController;
use Illuminate\Http\Request;
Route::get('/', function () {
return view('home');
});
-Route::get('/env', [SiteController::class, 'env']);
+Route::get('/dq', [SiteController::class, 'duneQuote'])->middleware('throttle:public');
-Route::get('/dq', [SiteController::class, 'duneQuote']);
-
-//temporarily desabled for security
-//Route::post('/f', [SiteController::class, 'uploadFiles']); //TODO later use file resource or FPR service
Route::get('/f/{path}', function($path){
+ // Sanitize: strip directory traversal, only allow basename
+ $path = basename($path);
$storpath = storage_path('app/public/uploads/' . $path);
if (!file_exists($storpath)){
- return response()->json(['error' => 'file not found ' . $storpath]);
+ abort(404, 'File not found.');
}
- $mime = Storage::mimeType($storpath);
-
- //return response()->json([
- // 'success' => true,
- // 'message' => 'file uploaded',
- // 'url' => $storpath,
- //]);
-
-// this is how you would return the file which would download the file in browser
+ $mime = Storage::mimeType('public/uploads/' . $path);
return response()->file($storpath, [
'Content-Type' => $mime,
'Content-Disposition' => 'inline; filename="'.$path.'"'
]);
-});
-
-/*Route::get('/yt', function () {
- return view('youtube');
-});*/
-
-// Get video info and direct stream URL (recommended approach)
-//Route::post('/yt/info', [YouTubeController::class, 'getVideoInfo']);
-// Stream video through Laravel proxy (with range support)
-//Route::get('/yt/stream', [YouTubeController::class, 'streamVideo']);
-// Stream directly via yt-dlp (not recommended - no seeking support)
-//Route::get('/yt/stream-direct', [YouTubeController::class, 'streamDirect']);
-// Get available formats for a video
-//Route::post('/yt/formats', [YouTubeController::class, 'getFormats']);
+})->middleware('throttle:public');
Route::get('/mu/{path}', function($path){
+ // Sanitize: strip directory traversal, only allow basename
+ $path = basename($path);
$storpath = storage_path('app/public/band/' . $path);
if (!file_exists($storpath)){
- return response()->json(['error' => 'file not found ' . $storpath]);
+ abort(404, 'File not found.');
}
- $mime = Storage::mimeType($storpath);
+ $mime = Storage::mimeType('public/band/' . $path);
return response()->file($storpath, [
'Content-Type' => $mime,
'Content-Disposition' => 'inline; filename="'.$path.'"'
]);
-});
-
-Route::resource('w', WritingController::class);
-Route::resource('l', LinkController::class);
+})->middleware('throttle:public');
// Presence - Public routes
Route::get('/me', [PresenceController::class, 'index']);
@@ -85,43 +63,62 @@ Route::middleware('auth')->prefix('me')->group(function () {
Route::get('/test', [SiteController::class, 'test']);
-Route::get('/4', [SiteController::class, 'search4chan']);
-
-Route::get('/dashboard', function () {
- return view('dashboard');
-})->middleware(['auth', 'verified'])->name('dashboard');
-
+// Auth-protected routes
Route::middleware('auth')->group(function () {
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
Route::get('/dashboard/stats', [DashboardController::class, 'statistics'])->name('dashboard.stats');
+ Route::resource('l', LinkController::class)->only(['create', 'store', 'edit', 'update', 'destroy']);
+ Route::resource('w', WritingController::class)->only(['create', 'store', 'edit', 'update', 'destroy']);
+ Route::resource('f', \App\Http\Controllers\FileController::class)->only(['create', 'store', 'edit', 'update', 'destroy']);
+});
+
+// Admin-only routes
+Route::middleware(['auth', 'throttle:admin', \App\Http\Middleware\Admin::class])->group(function () {
+ Route::get('/admin', [AdminController::class, 'index'])->name('admin');
+ Route::post('/l/import', [LinkController::class, 'import'])->name('l.import');
+ Route::get('/env', [SiteController::class, 'env']);
+ Route::get('/4', [SiteController::class, 'search4chan']);
});
+// Public read-only routes
+Route::resource('w', WritingController::class)->only(['index', 'show']);
+Route::resource('l', LinkController::class)->only(['index', 'show']);
+Route::resource('f', \App\Http\Controllers\FileController::class)->only(['index', 'show']);
+
+// Session updates - auth required, whitelisted keys only
Route::post('/update-session', function(Request $req){
if ($req->input('key') && $req->input('value')){
+ $allowed = ['theme', 'locale', 'sidebar'];
+ if (!in_array($req->input('key'), $allowed)) {
+ abort(403, 'Session key not allowed.');
+ }
session()->put($req->input('key'), $req->input('value'));
return redirect()->back();
}
-});
+})->middleware('auth');
require __DIR__.'/auth.php';
-Route::get('/toy/{v}', function($v){
+Route::get('/toy/{v?}', function($v = null){
if (is_null($v)){
return view('toys.index');
}
+ // Only allow alphanumeric and hyphens
+ if (!preg_match('/^[a-zA-Z0-9\-]+$/', $v)) {
+ abort(404);
+ }
+ if (!view()->exists('toys.'.$v)) {
+ abort(404);
+ }
return view('toys.'.$v);
});
-// Audio stream endpoint - streams from ffmpeg
+// Audio stream - requires auth to prevent abuse
Route::get('/stream/audio', function() {
- // Configure ffmpeg command to output mp3 stream
- // Example: stream from microphone or other source
- //$command = 'ffmpeg -re -i /dev/null -f mp3 -';
- //$command = 'ffmpeg -re -f pulse -i defaul -f mp3 -';
- $command = 'ffmpeg -re -i http://somafm.com/vaporwaves.pls :-f mp3 -';
+ $command = 'ffmpeg -re -i http://somafm.com/vaporwaves.pls -f mp3 -';
return response()->stream(function() use ($command) {
$handle = popen($command, 'r');
@@ -141,12 +138,17 @@ Route::get('/stream/audio', function() {
'Cache-Control' => 'no-cache',
'X-Accel-Buffering' => 'no'
]);
-});
+})->middleware(['auth', 'throttle:expensive']);
Route::get('/newtab', function() {
return view('newtab');
});
+// Catch-all: only allow specific whitelisted views
Route::get('/{v}', function($v){
+ $allowed = ['notes', 'kyanite', 'marked', 'v'];
+ if (!in_array($v, $allowed)) {
+ abort(404);
+ }
return view($v);
-});
+})->where('v', '[a-zA-Z0-9\-]+');
diff --git a/tests/Feature/WritingTest.php b/tests/Feature/WritingTest.php
new file mode 100644
index 0000000..5a88b17
--- /dev/null
+++ b/tests/Feature/WritingTest.php
@@ -0,0 +1,279 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\User;
+use App\Models\Writing;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class WritingTest extends TestCase
+{
+ use RefreshDatabase;
+
+ // ─── Index ───────────────────────────────────────────────────────────────
+
+ public function test_guests_can_view_index(): void
+ {
+ $response = $this->get(route('w.index'));
+ $response->assertOk();
+ }
+
+ public function test_index_lists_writings(): void
+ {
+ $writing = Writing::factory()->create();
+
+ $response = $this->get(route('w.index'));
+ $response->assertSee($writing->title);
+ }
+
+ // ─── Show ─────────────────────────────────────────────────────────────────
+
+ public function test_guests_can_view_a_writing(): void
+ {
+ $writing = Writing::factory()->create();
+
+ $response = $this->get(route('w.show', $writing));
+ $response->assertOk()->assertSee($writing->title);
+ }
+
+ public function test_show_404s_for_missing_writing(): void
+ {
+ $response = $this->get(route('w.show', 99999));
+ $response->assertNotFound();
+ }
+
+ // ─── Create ───────────────────────────────────────────────────────────────
+
+ public function test_guest_is_redirected_from_create(): void
+ {
+ $response = $this->get(route('w.create'));
+ $response->assertRedirect(route('login'));
+ }
+
+ public function test_authenticated_user_can_see_create_form(): void
+ {
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->get(route('w.create'));
+ $response->assertOk();
+ }
+
+ // ─── Store ────────────────────────────────────────────────────────────────
+
+ public function test_guest_cannot_store_writing(): void
+ {
+ $response = $this->post(route('w.store'), [
+ 'title' => 'Guest Writing',
+ 'content' => 'Should not be stored at all.',
+ ]);
+ $response->assertRedirect(route('login'));
+ $this->assertDatabaseMissing('writings', ['title' => 'Guest Writing']);
+ }
+
+ public function test_user_can_store_writing(): void
+ {
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->post(route('w.store'), [
+ 'title' => 'My New Writing',
+ 'content' => 'This is content that is long enough to pass validation.',
+ ]);
+
+ $response->assertRedirect();
+ $this->assertDatabaseHas('writings', [
+ 'title' => 'My New Writing',
+ 'user_id' => $user->id,
+ ]);
+ }
+
+ public function test_store_requires_title(): void
+ {
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->post(route('w.store'), [
+ 'title' => '',
+ 'content' => 'Content long enough to pass.',
+ ]);
+ $response->assertSessionHasErrors('title');
+ }
+
+ public function test_store_requires_title_min_3_chars(): void
+ {
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->post(route('w.store'), [
+ 'title' => 'AB',
+ 'content' => 'Content long enough to pass.',
+ ]);
+ $response->assertSessionHasErrors('title');
+ }
+
+ public function test_store_requires_content(): void
+ {
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->post(route('w.store'), [
+ 'title' => 'Valid Title',
+ 'content' => '',
+ ]);
+ $response->assertSessionHasErrors('content');
+ }
+
+ public function test_store_requires_content_min_10_chars(): void
+ {
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->post(route('w.store'), [
+ 'title' => 'Valid Title',
+ 'content' => 'Too short',
+ ]);
+ $response->assertSessionHasErrors('content');
+ }
+
+ // ─── Edit ─────────────────────────────────────────────────────────────────
+
+ public function test_guest_is_redirected_from_edit(): void
+ {
+ $writing = Writing::factory()->create();
+
+ $response = $this->get(route('w.edit', $writing));
+ $response->assertRedirect(route('login'));
+ }
+
+ public function test_owner_can_access_edit_form(): void
+ {
+ $user = User::factory()->create();
+ $writing = Writing::factory()->create(['user_id' => $user->id]);
+
+ $response = $this->actingAs($user)->get(route('w.edit', $writing));
+ $response->assertOk();
+ }
+
+ public function test_non_owner_cannot_access_edit_form(): void
+ {
+ $owner = User::factory()->create();
+ $other = User::factory()->create();
+ $writing = Writing::factory()->create(['user_id' => $owner->id]);
+
+ $response = $this->actingAs($other)->get(route('w.edit', $writing));
+ $response->assertForbidden();
+ }
+
+ public function test_admin_can_access_any_edit_form(): void
+ {
+ $owner = User::factory()->create();
+ $admin = User::factory()->create(['role' => 0]);
+ $writing = Writing::factory()->create(['user_id' => $owner->id]);
+
+ $response = $this->actingAs($admin)->get(route('w.edit', $writing));
+ $response->assertOk();
+ }
+
+ // ─── Update ───────────────────────────────────────────────────────────────
+
+ public function test_owner_can_update_writing(): void
+ {
+ $user = User::factory()->create();
+ $writing = Writing::factory()->create(['user_id' => $user->id]);
+
+ $response = $this->actingAs($user)->put(route('w.update', $writing), [
+ 'title' => 'Updated Title',
+ 'content' => 'Updated content that is long enough to pass validation.',
+ ]);
+
+ $response->assertRedirect(route('w.show', $writing));
+ $this->assertDatabaseHas('writings', [
+ 'id' => $writing->id,
+ 'title' => 'Updated Title',
+ ]);
+ }
+
+ public function test_non_owner_cannot_update_writing(): void
+ {
+ $owner = User::factory()->create();
+ $other = User::factory()->create();
+ $writing = Writing::factory()->create(['user_id' => $owner->id, 'title' => 'Original Title']);
+
+ $response = $this->actingAs($other)->put(route('w.update', $writing), [
+ 'title' => 'Hijacked Title',
+ 'content' => 'Hijacked content that is long enough.',
+ ]);
+
+ $response->assertForbidden();
+ $this->assertDatabaseHas('writings', ['id' => $writing->id, 'title' => 'Original Title']);
+ }
+
+ public function test_admin_can_update_any_writing(): void
+ {
+ $owner = User::factory()->create();
+ $admin = User::factory()->create(['role' => 0]);
+ $writing = Writing::factory()->create(['user_id' => $owner->id]);
+
+ $response = $this->actingAs($admin)->put(route('w.update', $writing), [
+ 'title' => 'Admin Updated Title',
+ 'content' => 'Admin updated content that is long enough.',
+ ]);
+
+ $response->assertRedirect(route('w.show', $writing));
+ $this->assertDatabaseHas('writings', ['id' => $writing->id, 'title' => 'Admin Updated Title']);
+ }
+
+ public function test_update_validates_title(): void
+ {
+ $user = User::factory()->create();
+ $writing = Writing::factory()->create(['user_id' => $user->id]);
+
+ $response = $this->actingAs($user)->put(route('w.update', $writing), [
+ 'title' => 'AB',
+ 'content' => 'Content long enough to pass.',
+ ]);
+ $response->assertSessionHasErrors('title');
+ }
+
+ // ─── Destroy ─────────────────────────────────────────────────────────────
+
+ public function test_guest_cannot_delete_writing(): void
+ {
+ $writing = Writing::factory()->create();
+
+ $response = $this->delete(route('w.destroy', $writing));
+ $response->assertRedirect(route('login'));
+ $this->assertDatabaseHas('writings', ['id' => $writing->id]);
+ }
+
+ public function test_owner_can_delete_writing(): void
+ {
+ $user = User::factory()->create();
+ $writing = Writing::factory()->create(['user_id' => $user->id]);
+
+ $response = $this->actingAs($user)->delete(route('w.destroy', $writing));
+
+ $response->assertRedirect(route('w.index'));
+ $this->assertDatabaseMissing('writings', ['id' => $writing->id]);
+ }
+
+ public function test_non_owner_cannot_delete_writing(): void
+ {
+ $owner = User::factory()->create();
+ $other = User::factory()->create();
+ $writing = Writing::factory()->create(['user_id' => $owner->id]);
+
+ $response = $this->actingAs($other)->delete(route('w.destroy', $writing));
+
+ $response->assertForbidden();
+ $this->assertDatabaseHas('writings', ['id' => $writing->id]);
+ }
+
+ public function test_admin_can_delete_any_writing(): void
+ {
+ $owner = User::factory()->create();
+ $admin = User::factory()->create(['role' => 0]);
+ $writing = Writing::factory()->create(['user_id' => $owner->id]);
+
+ $response = $this->actingAs($admin)->delete(route('w.destroy', $writing));
+
+ $response->assertRedirect(route('w.index'));
+ $this->assertDatabaseMissing('writings', ['id' => $writing->id]);
+ }
+}
diff --git a/vite.config.js b/vite.config.js
index 84c2295..9f67760 100755
--- a/vite.config.js
+++ b/vite.config.js
@@ -5,19 +5,9 @@ export default defineConfig({
plugins: [
laravel({
input: [
- 'resources/css/app.css',
- 'resources/css/skeleton.css',
- 'resources/css/normalize.css',
- 'resources/css/style.css',
- 'resources/css/writings.css',
- 'resources/css/jstoys.css',
- 'resources/js/main.js',
- 'resources/js/marked.js',
- 'resources/js/writing_index.js',
- 'resources/js/writing_create.js',
- 'resources/js/writing_show.js',
- 'resources/js/blood_gpu.js',
- 'resources/js/blood.js'],
+ 'resources/js/app.js',
+ 'resources/js/writing_create.js',
+ ],
refresh: true,
}),
],