Skip to content

Commit 6ce8d2a

Browse files
authored
Merge PR #1
PR-Stuff
2 parents 86c9e45 + 52d4ec4 commit 6ce8d2a

118 files changed

Lines changed: 4924 additions & 2387 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# StaticHTMLSites
2+
3+
A Laravel 12 application for hosting static HTML mini-sites.
4+
Each page is accessible via subdomain (`{slug}.statichtmlsites.mtex.dev`) and path URL (`statichtmlsites.mtex.dev/{slug}/...`).
5+
6+
---
7+
8+
## Tech Stack
9+
10+
| Layer | Technology |
11+
|----------|----------------------------------|
12+
| Backend | Laravel 12, PHP 8.2+ |
13+
| Frontend | Tailwind CSS 3, Alpine.js 3 |
14+
| Editor | CodeMirror 6 (npm) |
15+
| Build | Vite 5 + laravel-vite-plugin |
16+
| Auth | Laravel Breeze (blade stack) |
17+
| Database | SQLite (default) or MySQL |
18+
| Storage | Local filesystem |
19+
20+
---
21+
22+
## Installation
23+
24+
```bash
25+
# 1. Install PHP + JS dependencies
26+
composer install
27+
npm install
28+
29+
# 2. Environment
30+
cp .env.example .env
31+
php artisan key:generate
32+
33+
# 3. Migrate
34+
php artisan migrate
35+
36+
# 4. Install Breeze auth scaffolding
37+
php artisan breeze:install blade
38+
php artisan migrate
39+
40+
# 5. Build assets
41+
npm run build
42+
```
43+
44+
Key `.env` variables:
45+
46+
| Variable | Value |
47+
|-------------------|--------------------------------------|
48+
| APP_URL | https://statichtmlsites.mtex.dev |
49+
| APP_BASE_DOMAIN | statichtmlsites.mtex.dev |
50+
| MAX_UPLOAD_MB | 50 |
51+
52+
---
53+
54+
## Wildcard Subdomain (DNS + Nginx)
55+
56+
**DNS:**
57+
```
58+
*.statichtmlsites.mtex.dev CNAME statichtmlsites.mtex.dev
59+
```
60+
61+
**Nginx:**
62+
```nginx
63+
server {
64+
listen 443 ssl;
65+
server_name statichtmlsites.mtex.dev *.statichtmlsites.mtex.dev;
66+
ssl_certificate /path/to/wildcard.crt;
67+
ssl_certificate_key /path/to/wildcard.key;
68+
root /var/www/statichtmlsites/public;
69+
index index.php;
70+
location / { try_files $uri $uri/ /index.php?$query_string; }
71+
location ~ \.php$ {
72+
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
73+
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
74+
include fastcgi_params;
75+
}
76+
}
77+
```
78+
79+
**config/filesystems.php** — add to the returned array:
80+
```php
81+
'max_upload_mb' => env('MAX_UPLOAD_MB', 50),
82+
```
83+
84+
---
85+
86+
## Project Structure
87+
88+
```
89+
app/
90+
Http/Controllers/
91+
DashboardController.php Dashboard page
92+
FileManagerController.php File CRUD JSON API
93+
PageController.php Create / update / delete pages
94+
PageServeController.php Serve static files + <base> injection
95+
Models/
96+
Page.php ULID model, storage helpers, slug uniqueness
97+
User.php Breeze user + pages() HasMany
98+
Policies/PagePolicy.php Ownership checks
99+
Providers/AppServiceProvider.php Register FileManagerService singleton + policy
100+
Services/FileManagerService.php All file ops: tree, read, save, upload, delete, rename
101+
102+
resources/
103+
css/app.css Tailwind + CodeMirror overrides
104+
js/
105+
app.js Global entry (Alpine on non-manager pages)
106+
file-manager.js CodeMirror 6 + Alpine file-tree component
107+
views/
108+
layouts/app.blade.php App shell with nav
109+
welcome.blade.php Marketing landing
110+
pages/
111+
dashboard.blade.php Page grid
112+
create.blade.php New page form
113+
manager.blade.php Full-screen file manager
114+
115+
database/migrations/
116+
..._create_pages_table.php
117+
118+
routes/web.php All routes + subdomain group
119+
```
120+
121+
---
122+
123+
## Key Design Decisions
124+
125+
- **`<base>` injection**`PageServeController` detects subdomain vs. path access and injects `<base href="...">` into every HTML file before serving, keeping relative asset paths correct under both URL shapes.
126+
- **Path traversal guard**`FileManagerService::guard()` and `PageServeController` both resolve real paths and assert they sit inside the page's storage root.
127+
- **Storage isolation**`storage/app/pages/{slug}/`. Directories are auto-created on page creation, renamed on slug change, and deleted on page deletion via Eloquent model events.
128+
- **File tree** — Pure PHP recursive directory scan returned as JSON, rendered by an Alpine component with inline HTML strings.
129+
130+
---
131+
132+
## License
133+
MIT
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Models\Page;
6+
use Illuminate\Console\Command;
7+
use Illuminate\Support\Facades\Storage;
8+
9+
/**
10+
* Removes storage directories that no longer have a matching Page row.
11+
* Scheduled daily via routes/console.php.
12+
*
13+
* Usage: php artisan pages:cleanup-storage [--dry-run]
14+
*/
15+
class CleanupPageStorage extends Command
16+
{
17+
protected $signature = 'pages:cleanup-storage {--dry-run : List orphaned directories without deleting}';
18+
protected $description = 'Remove storage directories for deleted pages.';
19+
20+
public function handle(): int
21+
{
22+
$disk = Storage::disk('pages');
23+
24+
// Top-level directories on the pages disk are page slugs
25+
$dirs = $disk->directories('/');
26+
27+
$slugs = Page::pluck('slug')->flip(); // O(1) lookups
28+
$orphans = collect($dirs)->filter(fn ($d) => ! $slugs->has($d));
29+
30+
if ($orphans->isEmpty()) {
31+
$this->info('No orphaned directories found.');
32+
return self::SUCCESS;
33+
}
34+
35+
$dryRun = $this->option('dry-run');
36+
37+
foreach ($orphans as $dir) {
38+
if ($dryRun) {
39+
$this->line(" [dry-run] Would delete: {$dir}");
40+
} else {
41+
$disk->deleteDirectory($dir);
42+
$this->line(" Deleted: {$dir}");
43+
}
44+
}
45+
46+
$count = $orphans->count();
47+
$verb = $dryRun ? 'Found' : 'Cleaned up';
48+
$this->info("{$verb} {$count} orphaned " . str('directory')->plural($count) . '.');
49+
50+
return self::SUCCESS;
51+
}
52+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Auth;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Http\Requests\Auth\LoginRequest;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Illuminate\Support\Facades\Auth;
10+
use Illuminate\View\View;
11+
12+
class AuthenticatedSessionController extends Controller
13+
{
14+
public function create(): View
15+
{
16+
return view('auth.login');
17+
}
18+
19+
public function store(LoginRequest $request): RedirectResponse
20+
{
21+
$request->authenticate();
22+
$request->session()->regenerate();
23+
24+
return redirect()->intended(route('dashboard', absolute: false));
25+
}
26+
27+
public function destroy(Request $request): RedirectResponse
28+
{
29+
Auth::guard('web')->logout();
30+
31+
$request->session()->invalidate();
32+
$request->session()->regenerateToken();
33+
34+
return redirect('/');
35+
}
36+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Auth;
4+
5+
use App\Http\Controllers\Controller;
6+
use Illuminate\Http\RedirectResponse;
7+
use Illuminate\Http\Request;
8+
use Illuminate\Support\Facades\Auth;
9+
use Illuminate\Validation\ValidationException;
10+
use Illuminate\View\View;
11+
12+
class ConfirmablePasswordController extends Controller
13+
{
14+
public function show(): View
15+
{
16+
return view('auth.confirm-password');
17+
}
18+
19+
public function store(Request $request): RedirectResponse
20+
{
21+
if (! Auth::guard('web')->validate([
22+
'email' => $request->user()->email,
23+
'password' => $request->password,
24+
])) {
25+
throw ValidationException::withMessages([
26+
'password' => __('auth.password'),
27+
]);
28+
}
29+
30+
$request->session()->put('auth.password_confirmed_at', time());
31+
32+
return redirect()->intended(route('dashboard', absolute: false));
33+
}
34+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Auth;
4+
5+
use App\Http\Controllers\Controller;
6+
use Illuminate\Http\RedirectResponse;
7+
use Illuminate\Http\Request;
8+
9+
class EmailVerificationNotificationController extends Controller
10+
{
11+
public function store(Request $request): RedirectResponse
12+
{
13+
if ($request->user()->hasVerifiedEmail()) {
14+
return redirect()->intended(route('dashboard', absolute: false));
15+
}
16+
17+
$request->user()->sendEmailVerificationNotification();
18+
19+
return back()->with('status', 'verification-link-sent');
20+
}
21+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Auth;
4+
5+
use App\Http\Controllers\Controller;
6+
use Illuminate\Http\RedirectResponse;
7+
use Illuminate\Http\Request;
8+
use Illuminate\View\View;
9+
10+
class EmailVerificationPromptController extends Controller
11+
{
12+
/**
13+
* Invokable single-action controller.
14+
* Routes use it directly without specifying a method:
15+
* Route::get('verify-email', EmailVerificationPromptController::class)
16+
*/
17+
public function __invoke(Request $request): RedirectResponse|View
18+
{
19+
return $request->user()->hasVerifiedEmail()
20+
? redirect()->intended(route('dashboard', absolute: false))
21+
: view('auth.verify-email');
22+
}
23+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Auth;
4+
5+
use App\Http\Controllers\Controller;
6+
use Illuminate\Auth\Events\PasswordReset;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Illuminate\Support\Facades\Hash;
10+
use Illuminate\Support\Facades\Password;
11+
use Illuminate\Support\Str;
12+
use Illuminate\Validation\Rules;
13+
use Illuminate\View\View;
14+
15+
class NewPasswordController extends Controller
16+
{
17+
public function create(Request $request): View
18+
{
19+
return view('auth.reset-password', ['request' => $request]);
20+
}
21+
22+
public function store(Request $request): RedirectResponse
23+
{
24+
$request->validate([
25+
'token' => ['required'],
26+
'email' => ['required', 'email'],
27+
'password' => ['required', 'confirmed', Rules\Password::defaults()],
28+
]);
29+
30+
$status = Password::reset(
31+
$request->only('email', 'password', 'password_confirmation', 'token'),
32+
function ($user) use ($request) {
33+
$user->forceFill([
34+
'password' => Hash::make($request->password),
35+
'remember_token' => Str::random(60),
36+
])->save();
37+
38+
event(new PasswordReset($user));
39+
}
40+
);
41+
42+
return $status === Password::PASSWORD_RESET
43+
? redirect()->route('login')->with('status', __($status))
44+
: back()->withInput($request->only('email'))
45+
->withErrors(['email' => __($status)]);
46+
}
47+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Auth;
4+
5+
use App\Http\Controllers\Controller;
6+
use Illuminate\Http\RedirectResponse;
7+
use Illuminate\Http\Request;
8+
use Illuminate\Support\Facades\Hash;
9+
use Illuminate\Validation\Rules\Password;
10+
11+
class PasswordController extends Controller
12+
{
13+
/**
14+
* Update the user's password (from a profile/settings form).
15+
*/
16+
public function update(Request $request): RedirectResponse
17+
{
18+
$validated = $request->validateWithBag('updatePassword', [
19+
'current_password' => ['required', 'current_password'],
20+
'password' => ['required', Password::defaults(), 'confirmed'],
21+
]);
22+
23+
$request->user()->update([
24+
'password' => Hash::make($validated['password']),
25+
]);
26+
27+
return back()->with('status', 'password-updated');
28+
}
29+
}

0 commit comments

Comments
 (0)