Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# StaticHTMLSites

A Laravel 12 application for hosting static HTML mini-sites.
Each page is accessible via subdomain (`{slug}.statichtmlsites.mtex.dev`) and path URL (`statichtmlsites.mtex.dev/{slug}/...`).

---

## Tech Stack

| Layer | Technology |
|----------|----------------------------------|
| Backend | Laravel 12, PHP 8.2+ |
| Frontend | Tailwind CSS 3, Alpine.js 3 |
| Editor | CodeMirror 6 (npm) |
| Build | Vite 5 + laravel-vite-plugin |
| Auth | Laravel Breeze (blade stack) |
| Database | SQLite (default) or MySQL |
| Storage | Local filesystem |

---

## Installation

```bash
# 1. Install PHP + JS dependencies
composer install
npm install

# 2. Environment
cp .env.example .env
php artisan key:generate

# 3. Migrate
php artisan migrate

# 4. Install Breeze auth scaffolding
php artisan breeze:install blade
php artisan migrate

# 5. Build assets
npm run build
```

Key `.env` variables:

| Variable | Value |
|-------------------|--------------------------------------|
| APP_URL | https://statichtmlsites.mtex.dev |
| APP_BASE_DOMAIN | statichtmlsites.mtex.dev |
| MAX_UPLOAD_MB | 50 |

---

## Wildcard Subdomain (DNS + Nginx)

**DNS:**
```
*.statichtmlsites.mtex.dev CNAME statichtmlsites.mtex.dev
```

**Nginx:**
```nginx
server {
listen 443 ssl;
server_name statichtmlsites.mtex.dev *.statichtmlsites.mtex.dev;
ssl_certificate /path/to/wildcard.crt;
ssl_certificate_key /path/to/wildcard.key;
root /var/www/statichtmlsites/public;
index index.php;
location / { try_files $uri $uri/ /index.php?$query_string; }
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
}
```

**config/filesystems.php** — add to the returned array:
```php
'max_upload_mb' => env('MAX_UPLOAD_MB', 50),
```

---

## Project Structure

```
app/
Http/Controllers/
DashboardController.php Dashboard page
FileManagerController.php File CRUD JSON API
PageController.php Create / update / delete pages
PageServeController.php Serve static files + <base> injection
Models/
Page.php ULID model, storage helpers, slug uniqueness
User.php Breeze user + pages() HasMany
Policies/PagePolicy.php Ownership checks
Providers/AppServiceProvider.php Register FileManagerService singleton + policy
Services/FileManagerService.php All file ops: tree, read, save, upload, delete, rename

resources/
css/app.css Tailwind + CodeMirror overrides
js/
app.js Global entry (Alpine on non-manager pages)
file-manager.js CodeMirror 6 + Alpine file-tree component
views/
layouts/app.blade.php App shell with nav
welcome.blade.php Marketing landing
pages/
dashboard.blade.php Page grid
create.blade.php New page form
manager.blade.php Full-screen file manager

database/migrations/
..._create_pages_table.php

routes/web.php All routes + subdomain group
```

---

## Key Design Decisions

- **`<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.
- **Path traversal guard** — `FileManagerService::guard()` and `PageServeController` both resolve real paths and assert they sit inside the page's storage root.
- **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.
- **File tree** — Pure PHP recursive directory scan returned as JSON, rendered by an Alpine component with inline HTML strings.

---

## License
MIT
52 changes: 52 additions & 0 deletions app/Console/Commands/CleanupPageStorage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

namespace App\Console\Commands;

use App\Models\Page;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;

/**
* Removes storage directories that no longer have a matching Page row.
* Scheduled daily via routes/console.php.
*
* Usage: php artisan pages:cleanup-storage [--dry-run]
*/
class CleanupPageStorage extends Command
{
protected $signature = 'pages:cleanup-storage {--dry-run : List orphaned directories without deleting}';
protected $description = 'Remove storage directories for deleted pages.';

public function handle(): int
{
$disk = Storage::disk('pages');

// Top-level directories on the pages disk are page slugs
$dirs = $disk->directories('/');

$slugs = Page::pluck('slug')->flip(); // O(1) lookups
$orphans = collect($dirs)->filter(fn ($d) => ! $slugs->has($d));

if ($orphans->isEmpty()) {
$this->info('No orphaned directories found.');
return self::SUCCESS;
}

$dryRun = $this->option('dry-run');

foreach ($orphans as $dir) {
if ($dryRun) {
$this->line(" [dry-run] Would delete: {$dir}");
} else {
$disk->deleteDirectory($dir);
$this->line(" Deleted: {$dir}");
}
}

$count = $orphans->count();
$verb = $dryRun ? 'Found' : 'Cleaned up';
$this->info("{$verb} {$count} orphaned " . str('directory')->plural($count) . '.');

return self::SUCCESS;
}
}
36 changes: 36 additions & 0 deletions app/Http/Controllers/Auth/AuthenticatedSessionController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;

class AuthenticatedSessionController extends Controller
{
public function create(): View
{
return view('auth.login');
}

public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();

return redirect()->intended(route('dashboard', absolute: false));
}

public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();

$request->session()->invalidate();
$request->session()->regenerateToken();

return redirect('/');
}
}
34 changes: 34 additions & 0 deletions app/Http/Controllers/Auth/ConfirmablePasswordController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;

class ConfirmablePasswordController extends Controller
{
public function show(): View
{
return view('auth.confirm-password');
}

public function store(Request $request): RedirectResponse
{
if (! Auth::guard('web')->validate([
'email' => $request->user()->email,
'password' => $request->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}

$request->session()->put('auth.password_confirmed_at', time());

return redirect()->intended(route('dashboard', absolute: false));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

class EmailVerificationNotificationController extends Controller
{
public function store(Request $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false));
}

$request->user()->sendEmailVerificationNotification();

return back()->with('status', 'verification-link-sent');
}
}
23 changes: 23 additions & 0 deletions app/Http/Controllers/Auth/EmailVerificationPromptController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;

class EmailVerificationPromptController extends Controller
{
/**
* Invokable single-action controller.
* Routes use it directly without specifying a method:
* Route::get('verify-email', EmailVerificationPromptController::class)
*/
public function __invoke(Request $request): RedirectResponse|View
{
return $request->user()->hasVerifiedEmail()
? redirect()->intended(route('dashboard', absolute: false))
: view('auth.verify-email');
}
}
47 changes: 47 additions & 0 deletions app/Http/Controllers/Auth/NewPasswordController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\View\View;

class NewPasswordController extends Controller
{
public function create(Request $request): View
{
return view('auth.reset-password', ['request' => $request]);
}

public function store(Request $request): RedirectResponse
{
$request->validate([
'token' => ['required'],
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);

$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function ($user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();

event(new PasswordReset($user));
}
);

return $status === Password::PASSWORD_RESET
? redirect()->route('login')->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}
29 changes: 29 additions & 0 deletions app/Http/Controllers/Auth/PasswordController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;

class PasswordController extends Controller
{
/**
* Update the user's password (from a profile/settings form).
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validateWithBag('updatePassword', [
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);

$request->user()->update([
'password' => Hash::make($validated['password']),
]);

return back()->with('status', 'password-updated');
}
}
Loading
Loading