diff --git a/.claude/skills/fluxui-development/SKILL.md b/.claude/skills/fluxui-development/SKILL.md deleted file mode 100644 index cb88f22..0000000 --- a/.claude/skills/fluxui-development/SKILL.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -name: fluxui-development -description: "Develops UIs with Flux UI Free components. Activates when creating buttons, forms, modals, inputs, dropdowns, checkboxes, or UI components; replacing HTML form elements with Flux; working with flux: components; or when the user mentions Flux, component library, UI components, form fields, or asks about available Flux components." -license: MIT -metadata: - author: laravel ---- - -# Flux UI Development - -## When to Apply - -Activate this skill when: - -- Creating UI components or pages -- Working with forms, modals, or interactive elements -- Checking available Flux components - -## Documentation - -Use `search-docs` for detailed Flux UI patterns and documentation. - -## Basic Usage - -This project uses the free edition of Flux UI, which includes all free components and variants but not Pro components. - -Flux UI is a component library for Livewire built with Tailwind CSS. It provides components that are easy to use and customize. - -Use Flux UI components when available. Fall back to standard Blade components when no Flux component exists for your needs. - - -```blade -Click me -``` - -## Available Components (Free Edition) - -Available: avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, profile, radio, select, separator, skeleton, switch, text, textarea, tooltip - -## Icons - -Flux includes [Heroicons](https://heroicons.com/) as its default icon set. Search for exact icon names on the Heroicons site - do not guess or invent icon names. - - -```blade -Export -``` - -For icons not available in Heroicons, use [Lucide](https://lucide.dev/). Import the icons you need with the Artisan command: - -```bash -php artisan flux:icon crown grip-vertical github -``` - -## Common Patterns - -### Form Fields - - -```blade - - Email - - - -``` - -### Modals - - -```blade - - Title -

Content

-
-``` - -## Verification - -1. Check component renders correctly -2. Test interactive states -3. Verify mobile responsiveness - -## Common Pitfalls - -- Trying to use Pro-only components in the free edition -- Not checking if a Flux component exists before creating custom implementations -- Forgetting to use the `search-docs` tool for component-specific documentation -- Not following existing project patterns for Flux usage \ No newline at end of file diff --git a/.claude/skills/tailwindcss-development/SKILL.md b/.claude/skills/tailwindcss-development/SKILL.md new file mode 100644 index 0000000..21a7e46 --- /dev/null +++ b/.claude/skills/tailwindcss-development/SKILL.md @@ -0,0 +1,129 @@ +--- +name: tailwindcss-development +description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes." +license: MIT +metadata: + author: laravel +--- + +# Tailwind CSS Development + +## When to Apply + +Activate this skill when: + +- Adding styles to components or pages +- Working with responsive design +- Implementing dark mode +- Extracting repeated patterns into components +- Debugging spacing or layout issues + +## Documentation + +Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation. + +## Basic Usage + +- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns. +- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue). +- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically. + +## Tailwind CSS v4 Specifics + +- Always use Tailwind CSS v4 and avoid deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. + +### CSS-First Configuration + +In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed: + + +```css +@theme { + --color-brand: oklch(0.72 0.11 178); +} +``` + +### Import Syntax + +In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3: + + +```diff +- @tailwind base; +- @tailwind components; +- @tailwind utilities; ++ @import "tailwindcss"; +``` + +### Replaced Utilities + +Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric. + +| Deprecated | Replacement | +|------------|-------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + +## Spacing + +Use `gap` utilities instead of margins for spacing between siblings: + + +```html +
+
Item 1
+
Item 2
+
+``` + +## Dark Mode + +If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant: + + +```html +
+ Content adapts to color scheme +
+``` + +## Common Patterns + +### Flexbox Layout + + +```html +
+
Left content
+
Right content
+
+``` + +### Grid Layout + + +```html +
+
Card 1
+
Card 2
+
Card 3
+
+``` + +## Common Pitfalls + +- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.) +- Using `@tailwind` directives instead of `@import "tailwindcss"` +- Trying to use `tailwind.config.js` instead of CSS `@theme` directive +- Using margins for spacing between siblings instead of gap utilities +- Forgetting to add dark mode variants when the project uses dark mode \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f6ce8d5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,57 @@ +# Version control +.git +.gitattributes + +# Dependencies (built in Docker) +node_modules +vendor + +# Environment files +.env +.env.*.local + +# Development & IDE +.idea +.vscode +.phpunit.result.cache +.php-cs-fixer.cache + +# Docker files (avoid recursive copy) +docker-compose*.yml +Dockerfile + +# CI/CD +.github + +# Testing +tests +phpunit.xml +.phpunit.cache + +# Documentation +*.md +LICENSE + +# OS files +.DS_Store +Thumbs.db + +# Build artifacts +public/build +public/hot + +# Logs & framework cache (recreated at runtime) +storage/logs/* +storage/framework/cache/* +storage/framework/sessions/* +storage/framework/testing/* +storage/framework/views/* +storage/pail/* +!storage/logs/.gitkeep +!storage/framework/cache/.gitkeep +!storage/framework/sessions/.gitkeep +!storage/framework/testing/.gitkeep +!storage/framework/views/.gitkeep + +# Database (created at runtime) +database/database.sqlite diff --git a/.env.example b/.env.example index c0660ea..c4418f1 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ -APP_NAME=Laravel +APP_NAME=SealShare APP_ENV=local APP_KEY= -APP_DEBUG=true +APP_DEBUG=false APP_URL=http://localhost APP_LOCALE=en @@ -29,7 +29,7 @@ DB_CONNECTION=sqlite SESSION_DRIVER=database SESSION_LIFETIME=120 -SESSION_ENCRYPT=false +SESSION_ENCRYPT=true SESSION_PATH=/ SESSION_DOMAIN=null @@ -63,3 +63,11 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +# Octane / FrankenPHP +# OCTANE_SERVER=frankenphp +# OCTANE_HTTPS=false +# OCTANE_MAX_EXECUTION_TIME=300 + +# Docker (used only when deploying with docker-compose.yml) +# SERVER_NAME=share.example.com diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..8ed1085 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,118 @@ +name: docker + +on: + push: + branches: + - main + tags: + - "v*.*.*" + pull_request: + branches: + - main + +permissions: + contents: write + packages: write + +jobs: + test: + runs-on: ubuntu-latest + environment: Testing + strategy: + matrix: + php-version: ["8.5"] + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: pcntl + tools: composer:v2 + coverage: xdebug + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Install Node Dependencies + run: npm ci + + - name: Add Flux Credentials Loaded From ENV + run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_USERNAME }}" "${{ secrets.FLUX_LICENSE_KEY }}" + + - name: Install Dependencies + run: composer install --no-interaction --prefer-dist --optimize-autoloader + + - name: Copy Environment File + run: cp .env.example .env + + - name: Generate Application Key + run: php artisan key:generate + + - name: Build Assets + run: npm run build + + - name: Run Tests + run: ./vendor/bin/pest + + build-and-push: + runs-on: ubuntu-latest + needs: test + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=ref,event=branch + type=sha + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + release: + runs-on: ubuntu-latest + needs: build-and-push + if: startsWith(github.ref, 'refs/tags/v') + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3c53898..7175e23 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -27,7 +27,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.4' + php-version: '8.5' - name: Add Flux Credentials Loaded From ENV run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_USERNAME }}" "${{ secrets.FLUX_LICENSE_KEY }}" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7cfd2dd..93451e8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -20,7 +20,7 @@ jobs: environment: Testing strategy: matrix: - php-version: ['8.4', '8.5'] + php-version: ['8.5'] steps: - name: Checkout code @@ -30,13 +30,14 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} + extensions: pcntl tools: composer:v2 coverage: xdebug - name: Setup Node uses: actions/setup-node@v4 with: - node-version: '22' + node-version: '24' - name: Install Node Dependencies run: npm i diff --git a/.gitignore b/.gitignore index c7cf1fa..90d3d5f 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ yarn-error.log /.nova /.vscode /.zed + +**/caddy +frankenphp +frankenphp-worker.php diff --git a/CLAUDE.md b/CLAUDE.md index 0eed845..b599f72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,8 +12,8 @@ This application is a Laravel application and its main Laravel ecosystems packag - php - 8.4.3 - laravel/fortify (FORTIFY) - v1 - laravel/framework (LARAVEL) - v12 +- laravel/octane (OCTANE) - v2 - laravel/prompts (PROMPTS) - v0 -- livewire/flux (FLUXUI_FREE) - v2 - livewire/livewire (LIVEWIRE) - v4 - laravel/boost (BOOST) - v2 - laravel/mcp (MCP) - v0 @@ -22,14 +22,16 @@ This application is a Laravel application and its main Laravel ecosystems packag - laravel/sail (SAIL) - v1 - pestphp/pest (PEST) - v4 - phpunit/phpunit (PHPUNIT) - v12 +- alpinejs (ALPINEJS) - v3 +- tailwindcss (TAILWINDCSS) - v4 ## Skills Activation This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. -- `fluxui-development` — Develops UIs with Flux UI Free components. Activates when creating buttons, forms, modals, inputs, dropdowns, checkboxes, or UI components; replacing HTML form elements with Flux; working with flux: components; or when the user mentions Flux, component library, UI components, form fields, or asks about available Flux components. - `livewire-development` — Develops reactive Livewire 4 components. Activates when creating, updating, or modifying Livewire components; working with wire:model, wire:click, wire:loading, or any wire: directives; adding real-time updates, loading states, or reactivity; debugging component behavior; writing Livewire tests; or when the user mentions Livewire, component, counter, or reactive UI. - `pest-testing` — Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works. +- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes. - `developing-with-fortify` — Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications. ## Conventions @@ -223,14 +225,6 @@ protected function isAccessible(User $user, ?string $path = null): bool - Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. -=== fluxui-free/core rules === - -# Flux UI Free - -- Flux UI is the official Livewire component library. This project uses the free edition, which includes all free components and variants but not Pro components. -- Use `` components when available; they are the recommended way to build Livewire interfaces. -- IMPORTANT: Activate `fluxui-development` when working with Flux UI components. - === livewire/core rules === # Livewire @@ -297,6 +291,14 @@ protected function isAccessible(User $user, ?string $path = null): bool - CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples. - IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task. +=== tailwindcss/core rules === + +# Tailwind CSS + +- Always use existing Tailwind conventions; check project patterns before adding new ones. +- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data. +- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task. + === laravel/fortify rules === # Laravel Fortify diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2dd8414 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,102 @@ +# ============================================ +# Stage 1: Build frontend assets +# ============================================ +FROM node:24-alpine AS assets + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci --prefer-offline + +COPY vite.config.js ./ +COPY resources/ ./resources/ + +RUN npm run build + +# ============================================ +# Stage 2: Install PHP dependencies +# ============================================ +FROM composer:2 AS vendor + +WORKDIR /app + +COPY composer.json composer.lock* ./ + +RUN composer install \ + --no-dev \ + --no-interaction \ + --no-autoloader \ + --no-scripts \ + --prefer-dist + +COPY . . + +RUN composer dump-autoload --optimize --no-dev + +# ============================================ +# Stage 3: Production image (FrankenPHP/Octane) +# ============================================ +FROM dunglas/frankenphp:php8.5-alpine AS production + +LABEL maintainer="surtic86" +LABEL org.opencontainers.image.source="https://github.com/surtic86/SealShare" +LABEL org.opencontainers.image.description="Self-hosted encrypted file sharing" + +# Install required PHP extensions +RUN install-php-extensions \ + intl \ + pcntl + +# Laravel environment defaults +ENV APP_NAME="SealShare" \ + APP_ENV="production" \ + APP_DEBUG="false" \ + APP_URL="http://localhost" \ + LOG_CHANNEL="stderr" \ + LOG_LEVEL="warning" \ + DB_CONNECTION="sqlite" \ + SESSION_DRIVER="database" \ + QUEUE_CONNECTION="database" \ + CACHE_STORE="database" \ + FILESYSTEM_DISK="local" \ + BROADCAST_CONNECTION="log" \ + BCRYPT_ROUNDS="12" \ + OCTANE_SERVER="frankenphp" + +WORKDIR /app + +# Copy Caddyfile +COPY docker/Caddyfile /etc/caddy/Caddyfile + +# Copy PHP ini for upload limits +COPY docker/php/uploads.ini /usr/local/etc/php/conf.d/99-uploads.ini + +# Copy application code +COPY . . + +# Copy vendor dependencies from composer stage +COPY --from=vendor /app/vendor ./vendor + +# Copy built frontend assets from node stage +COPY --from=assets /app/public/build ./public/build + +# Remove dev/build files not needed in production +RUN rm -rf node_modules tests .github docker/dev.Dockerfile docker/dev-entrypoint.sh .env .env.example \ + && mkdir -p storage/app/shares storage/app/public storage/framework/cache \ + storage/framework/sessions storage/framework/testing storage/framework/views \ + storage/logs database \ + && chmod -R 777 storage database bootstrap/cache + +# Create SQLite database file if it doesn't exist +RUN touch database/database.sqlite \ + && chmod 666 database/database.sqlite + +# Make entrypoint executable +RUN chmod +x docker/entrypoint.sh + +EXPOSE 80 443 443/udp + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl --silent --fail http://localhost/up || exit 1 + +ENTRYPOINT ["docker/entrypoint.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b500277 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 SealShare + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fbc21d9 --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +# SealShare + +A simple, self-hosted file sharing solution built with Laravel. Upload files, get a shareable link, done. All files are encrypted at rest with AES-256-GCM. + +## Features + +- **File Uploading** — Drag & drop or browse to upload single/multiple files and folders with real-time progress +- **Shareable Links** — Each upload generates a unique link for recipients +- **End-to-End Encryption** — All files encrypted at rest using AES-256-GCM (chunked, streaming) +- **Password Protection** — Optionally protect shares with a password +- **Expiration** — Shares auto-expire after a configurable duration (1 hour to 30 days) +- **Download Limits** — Set a maximum number of downloads per share +- **ZIP Downloads** — Download all files in a share as a single ZIP archive +- **Auto-Cleanup** — Expired shares and files are automatically deleted (hourly) +- **Admin Dashboard** — View, manage, and delete all shares +- **Admin Settings** — Configure upload limits, storage quotas, branding, and more +- **Site Branding** — Custom logo, title, and description +- **System Password** — Optional global password gate to restrict upload access +- **User Authentication** — Login, registration, password reset, email verification +- **Two-Factor Authentication** — TOTP-based 2FA via Laravel Fortify +- **Dark Mode** — Dark themed UI with DaisyUI components +- **Setup Wizard** — First-run wizard to create the initial admin account + +## Tech Stack + +| Layer | Technology | +|-------|-----------| +| **Framework** | Laravel 12 | +| **Application Server** | FrankenPHP (via Laravel Octane) | +| **Frontend** | Livewire 4, Alpine.js, Tailwind CSS 4, DaisyUI 5, Mary UI | +| **Authentication** | Laravel Fortify | +| **Encryption** | Chunked AES-256-GCM with PBKDF2-SHA256 key derivation | +| **ZIP Streaming** | maennchen/zipstream-php | +| **Testing** | Pest 4 | +| **Code Style** | Laravel Pint | +| **Build Tool** | Vite | + +## Installation — Development + +### Docker (recommended) + +```bash +# Build and start the dev container +docker compose -f docker-compose.dev.yml up -d --build + +# View logs (including Vite output) +docker compose -f docker-compose.dev.yml logs -f +``` + +The app is available at `http://localhost:8000` with Vite HMR on port `5173`. + + +## Installation — Production + +### Docker (recommended) + +```bash +mkdir sealshare && cd sealshare +curl -O https://raw.githubusercontent.com/surtic86/SealShare/main/docker-compose.example.yml +cp docker-compose.example.yml docker-compose.yml + +# Generate an app key and paste it into docker-compose.yml +docker run --rm ghcr.io/surtic86/sealshare:latest php artisan key:generate --show + +# Edit docker-compose.yml — set APP_KEY, APP_URL, and SERVER_NAME +# Then start: +docker compose up -d +``` + +Migrations run automatically on startup. Open your configured domain — the Setup Wizard will create the first admin account. + +**Key environment variables:** + +| Variable | Required | Description | +|----------|----------|-------------| +| `APP_KEY` | Yes | Laravel encryption key | +| `APP_URL` | Yes | Full URL (e.g. `https://share.example.com`) | +| `SERVER_NAME` | Yes | Domain for auto-TLS (e.g. `share.example.com`) | + +**Volumes:** + +| Volume | Path | Purpose | +|--------|------|---------| +| `sealshare_storage` | `/app/storage/app` | Encrypted uploaded files | +| `sealshare_database` | `/app/database` | SQLite database | +| `caddy_data` | `/data` | TLS certificates | +| `caddy_config` | `/config` | Caddy configuration | + +### Manual (without Docker) + +```bash +git clone https://github.com/surtic86/SealShare.git +cd SealShare + +composer install --no-dev --optimize-autoloader +npm install && npm run build + +cp .env.example .env +php artisan key:generate + +# Edit .env — set APP_ENV=production, APP_DEBUG=false, APP_URL=https://your-domain.com + +touch database/database.sqlite +php artisan migrate --force +php artisan storage:link + +php artisan config:cache +php artisan route:cache +php artisan view:cache +``` + +Start with Octane: + +```bash +php artisan octane:frankenphp --host=0.0.0.0 --port=80 +``` + +Or point your web server (Nginx/Apache) to the `public/` directory for a traditional PHP-FPM setup. + +Add the scheduler to your crontab: + +```bash +* * * * * cd /path-to-sealshare && php artisan schedule:run >> /dev/null 2>&1 +``` + +## License + +This project is open-source software licensed under the [MIT License](LICENSE). diff --git a/app/Console/Commands/CleanupExpiredShares.php b/app/Console/Commands/CleanupExpiredShares.php new file mode 100644 index 0000000..6c31714 --- /dev/null +++ b/app/Console/Commands/CleanupExpiredShares.php @@ -0,0 +1,34 @@ +where(function ($query): void { + $query->where('expires_at', '<', now()) + ->orWhereRaw('max_downloads IS NOT NULL AND download_count >= max_downloads'); + }) + ->get(); + + $count = $expiredShares->count(); + + foreach ($expiredShares as $share) { + $shareService->deleteShare($share); + } + + $this->info("Cleaned up {$count} expired share(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/DownloadController.php b/app/Http/Controllers/DownloadController.php new file mode 100644 index 0000000..5a1f897 --- /dev/null +++ b/app/Http/Controllers/DownloadController.php @@ -0,0 +1,97 @@ +isExpired() || $share->hasReachedDownloadLimit(), 404); + + $share->load('files'); + $key = $this->resolveDecryptionKey($share); + + $this->shareService->recordDownload($share); + + return new StreamedResponse(function () use ($share, $key): void { + $zip = new ZipStream( + outputName: 'share-'.$share->token.'.zip', + sendHttpHeaders: false, + ); + + foreach ($share->files as $file) { + $encryptedPath = Storage::disk('shares')->path($share->token.'/'.basename($file->stored_path)); + $callback = $this->encryptionService->decryptFileToCallback($encryptedPath, $key); + + $filename = $file->relative_path ?: $file->original_name; + $filename = str_replace('\\', '/', $filename); + + if (str_starts_with($filename, '/') || str_contains($filename, '..')) { + $filename = basename($filename); + } + + $zip->addFileFromCallback(fileName: $filename, callback: $callback, exactSize: $file->file_size); + } + + $zip->finish(); + }, 200, [ + 'Content-Type' => 'application/zip', + 'Content-Disposition' => 'attachment; filename="share-'.$share->token.'.zip"', + ]); + } + + /** + * Download a single file. + */ + public function downloadFile(Share $share, ShareFile $shareFile): StreamedResponse + { + abort_if($share->isExpired() || $share->hasReachedDownloadLimit(), 404); + abort_if($shareFile->share_id !== $share->id, 404); + + $key = $this->resolveDecryptionKey($share); + + $this->shareService->recordDownload($share); + + $encryptedPath = Storage::disk('shares')->path($share->token.'/'.basename($shareFile->stored_path)); + + return $this->encryptionService->decryptFileStream( + $encryptedPath, + $key, + $shareFile->original_name, + $shareFile->mime_type ?? 'application/octet-stream', + $shareFile->file_size, + ); + } + + /** + * Resolve the decryption key from session or share. + */ + private function resolveDecryptionKey(Share $share): string + { + if ($share->isPasswordProtected()) { + $key = session('share_key_'.$share->token); + + abort_if(! $key, 403, 'Password required'); + + return $key; + } + + return $this->shareService->getDecryptionKey($share); + } +} diff --git a/app/Http/Middleware/EnsureAdmin.php b/app/Http/Middleware/EnsureAdmin.php new file mode 100644 index 0000000..658e2db --- /dev/null +++ b/app/Http/Middleware/EnsureAdmin.php @@ -0,0 +1,19 @@ +user() || ! $request->user()->is_admin) { + abort(403); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/EnsureSetupComplete.php b/app/Http/Middleware/EnsureSetupComplete.php new file mode 100644 index 0000000..3a740b0 --- /dev/null +++ b/app/Http/Middleware/EnsureSetupComplete.php @@ -0,0 +1,20 @@ +where('is_admin', true)->exists() && ! $request->is('setup', 'setup/*', 'livewire*')) { + return redirect()->route('setup'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/SecurityHeaders.php b/app/Http/Middleware/SecurityHeaders.php new file mode 100644 index 0000000..571fd5f --- /dev/null +++ b/app/Http/Middleware/SecurityHeaders.php @@ -0,0 +1,27 @@ +headers->set('X-Content-Type-Options', 'nosniff'); + $response->headers->set('X-Frame-Options', 'DENY'); + $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); + $response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + + return $response; + } +} diff --git a/app/Http/Middleware/SystemPasswordGate.php b/app/Http/Middleware/SystemPasswordGate.php new file mode 100644 index 0000000..fe9b34f --- /dev/null +++ b/app/Http/Middleware/SystemPasswordGate.php @@ -0,0 +1,26 @@ +session()->get('system_password_verified') === true) { + return $next($request); + } + + return redirect()->route('system-password'); + } +} diff --git a/app/Livewire/Admin/AdminDashboard.php b/app/Livewire/Admin/AdminDashboard.php new file mode 100644 index 0000000..d07979f --- /dev/null +++ b/app/Livewire/Admin/AdminDashboard.php @@ -0,0 +1,62 @@ + */ + public array $sortBy = ['column' => 'created_at', 'direction' => 'desc']; + + public function deleteShare(int $shareId, ShareService $shareService): void + { + $share = Share::query()->findOrFail($shareId); + $shareService->deleteShare($share); + } + + /** + * @return array> + */ + public function headers(): array + { + return [ + ['key' => 'token', 'label' => __('Token')], + ['key' => 'files_count', 'label' => __('Files')], + ['key' => 'total_size', 'label' => __('Size')], + ['key' => 'download_count', 'label' => __('Downloads')], + ['key' => 'expires_at', 'label' => __('Expires')], + ['key' => 'created_at', 'label' => __('Created')], + ]; + } + + public function render(): mixed + { + $shareService = app(ShareService::class); + + $shares = Share::query() + ->withCount('files') + ->orderBy($this->sortBy['column'], $this->sortBy['direction']) + ->paginate(15); + + return view('livewire.admin.admin-dashboard', [ + 'shares' => $shares, + 'totalShares' => Share::query()->count(), + 'activeShares' => Share::query()->where(function ($q) { + $q->whereNull('expires_at')->orWhere('expires_at', '>', now()); + })->count(), + 'totalFiles' => ShareFile::query()->count(), + 'usedSpace' => $shareService->getTotalUsedSpace(), + 'maxQuota' => $shareService->getMaxStorageQuota(), + 'headers' => $this->headers(), + ]); + } +} diff --git a/app/Livewire/Admin/AdminSettings.php b/app/Livewire/Admin/AdminSettings.php new file mode 100644 index 0000000..c5728da --- /dev/null +++ b/app/Livewire/Admin/AdminSettings.php @@ -0,0 +1,146 @@ +defaultExpiration = Setting::get('default_expiration', '') ?? ''; + $this->maxFileSize = min( + (int) Setting::get('max_file_size', 100 * 1024 * 1024) / (1024 * 1024), + self::phpMaxUploadMb(), + ); + $this->maxStorageQuota = (int) Setting::get('max_storage_quota', 20 * 1024 * 1024 * 1024) / (1024 * 1024 * 1024); + $this->maxFilesPerShare = (int) Setting::get('max_files_per_share', 50); + $this->maxSizePerShare = (int) Setting::get('max_size_per_share', 2 * 1024 * 1024 * 1024) / (1024 * 1024 * 1024); + $this->allowNeverExpire = (bool) Setting::get('allow_never_expire', false); + $this->siteTitle = Setting::get('site_title', '') ?? ''; + $this->siteDescription = Setting::get('site_description', '') ?? ''; + } + + public static function phpMaxUploadMb(): int + { + $parse = function (string $value): int { + $value = trim($value); + $last = strtolower($value[strlen($value) - 1]); + $num = (int) $value; + + return match ($last) { + 'g' => $num * 1024, + 'm' => $num, + 'k' => max(1, (int) ($num / 1024)), + default => max(1, (int) ($num / (1024 * 1024))), + }; + }; + + $upload = $parse(ini_get('upload_max_filesize') ?: '2M'); + $post = $parse(ini_get('post_max_size') ?: '8M'); + + return min($upload, $post); + } + + public function saveSettings(): void + { + $phpMaxMb = self::phpMaxUploadMb(); + + $this->validate([ + 'maxFileSize' => ['required', 'integer', 'min:1', 'max:'.$phpMaxMb], + 'maxStorageQuota' => ['required', 'integer', 'min:1'], + 'maxFilesPerShare' => ['required', 'integer', 'min:1'], + 'maxSizePerShare' => ['required', 'integer', 'min:1'], + 'siteTitle' => ['nullable', 'string', 'max:255'], + 'siteDescription' => ['nullable', 'string', 'max:1000'], + 'siteLogo' => ['nullable', 'file', 'mimes:png,jpg,jpeg,gif,webp', 'max:2048'], + ], [ + 'maxFileSize.max' => __('Cannot exceed the PHP limit of :max MB. Increase upload_max_filesize and post_max_size in your PHP configuration.', ['max' => $phpMaxMb]), + ]); + + if ($this->systemPassword) { + Setting::set('system_password', Hash::make($this->systemPassword)); + } + + Setting::set('default_expiration', $this->defaultExpiration ?: null); + Setting::set('max_file_size', $this->maxFileSize * 1024 * 1024); + Setting::set('max_storage_quota', $this->maxStorageQuota * 1024 * 1024 * 1024); + Setting::set('max_files_per_share', $this->maxFilesPerShare); + Setting::set('max_size_per_share', $this->maxSizePerShare * 1024 * 1024 * 1024); + + Setting::set('allow_never_expire', $this->allowNeverExpire ? '1' : null); + Setting::set('site_title', $this->siteTitle ?: null); + Setting::set('site_description', $this->siteDescription ?: null); + + if ($this->siteLogo && is_object($this->siteLogo)) { + $existingLogo = Setting::get('site_logo'); + if ($existingLogo) { + Storage::disk('public')->delete($existingLogo); + } + + $path = $this->siteLogo->store('branding', 'public'); + Setting::set('site_logo', $path); + $this->siteLogo = null; + } + + $this->systemPassword = ''; + + session()->flash('message', __('Settings saved successfully.')); + } + + public function removeLogo(): void + { + $existingLogo = Setting::get('site_logo'); + + if ($existingLogo) { + Storage::disk('public')->delete($existingLogo); + Setting::set('site_logo', null); + } + + session()->flash('message', __('Logo removed.')); + } + + public function clearSystemPassword(): void + { + Setting::set('system_password', null); + + session()->flash('message', __('System password cleared.')); + } + + public function render(): mixed + { + return view('livewire.admin.admin-settings', [ + 'hasSystemPassword' => (bool) Setting::get('system_password'), + 'currentLogo' => Setting::get('site_logo'), + 'phpMaxUploadMb' => self::phpMaxUploadMb(), + ]); + } +} diff --git a/app/Livewire/FileUploader.php b/app/Livewire/FileUploader.php new file mode 100644 index 0000000..39a5d4b --- /dev/null +++ b/app/Livewire/FileUploader.php @@ -0,0 +1,192 @@ + */ + public array $files = []; + + /** @var array */ + public array $relativePaths = []; + + public bool $usePassword = false; + + public string $password = ''; + + public string $password_confirmation = ''; + + public string $expiration = '7d'; + + public ?int $maxDownloads = null; + + public function mount(): void + { + $this->expiration = Setting::get('default_expiration', '7d') ?: '7d'; + } + + public function _uploadErrored($name, $errorsInJson, $isMultiple): void + { + $this->dispatch('upload:errored', name: $name)->self(); + + $maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024); + $maxFileSizeMb = (int) ($maxFileSize / (1024 * 1024)); + + if (! is_null($errorsInJson)) { + $errors = json_decode($errorsInJson, true)['errors'] ?? null; + + if ($errors) { + $messages = []; + foreach ($errors as $messages_array) { + foreach ((array) $messages_array as $msg) { + $messages[] = $msg; + } + } + + throw ValidationException::withMessages([ + 'files' => __('Upload failed: file exceeds the maximum size of :max MB.', ['max' => $maxFileSizeMb]), + ]); + } + } + + throw ValidationException::withMessages([ + 'files' => __('Upload failed: file may be too large (max :max MB) or the connection was interrupted.', ['max' => $maxFileSizeMb]), + ]); + } + + public function updatedFiles(): void + { + $maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024); + $maxFileSizeMb = $maxFileSize / (1024 * 1024); + $maxFilesPerShare = (int) Setting::get('max_files_per_share', 50); + + $this->resetErrorBag('files'); + + if (count($this->files) > $maxFilesPerShare) { + $this->addError('files', __('Too many files. Maximum :max files allowed per share.', ['max' => $maxFilesPerShare])); + + return; + } + + foreach ($this->files as $file) { + if ($file->getSize() > $maxFileSize) { + $this->addError('files', __('":name" is too large (:size MB). Maximum file size is :max MB.', [ + 'name' => $file->getClientOriginalName(), + 'size' => round($file->getSize() / (1024 * 1024), 1), + 'max' => (int) $maxFileSizeMb, + ])); + + return; + } + } + } + + public function removeFile(int $index): void + { + unset($this->files[$index], $this->relativePaths[$index]); + $this->files = array_values($this->files); + $this->relativePaths = array_values($this->relativePaths); + } + + public function createShare(ShareService $shareService): void + { + $maxFilesPerShare = (int) Setting::get('max_files_per_share', 50); + $maxSizePerShare = (int) Setting::get('max_size_per_share', 2 * 1024 * 1024 * 1024); + $maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024); + + $rules = [ + 'files' => ['required', 'array', 'min:1', 'max:'.$maxFilesPerShare], + 'files.*' => ['required', 'file', 'max:'.($maxFileSize / 1024)], + ]; + + $allowNeverExpire = (bool) Setting::get('allow_never_expire', false); + + if (! $allowNeverExpire) { + $rules['expiration'] = ['required', 'string', 'in:1h,24h,48h,7d,14d,30d']; + } + + if ($this->usePassword) { + $rules['password'] = ['required', 'string', 'min:8']; + } + + $this->validate($rules, [ + 'expiration.required' => __('An expiration time is required.'), + 'files.required' => __('Please select at least one file to upload.'), + 'files.max' => __('Too many files. Maximum :max files allowed per share.'), + 'files.*.max' => __('A file exceeds the maximum size of :max KB.'), + ]); + + if ($shareService->isStorageFull()) { + $this->addError('files', __('Storage is full. Please contact the administrator.')); + + return; + } + + $totalSize = collect($this->files)->sum(fn ($file) => $file->getSize()); + + if ($totalSize > $maxSizePerShare) { + $this->addError('files', __('Total file size exceeds the maximum allowed per share.')); + + return; + } + + $fileData = []; + foreach ($this->files as $index => $file) { + $relativePath = $this->relativePaths[$index] ?? null; + + if ($relativePath !== null) { + $relativePath = str_replace('\\', '/', $relativePath); + + if (str_starts_with($relativePath, '/') || str_contains($relativePath, '..')) { + $relativePath = null; + } + } + + $fileData[] = [ + 'file' => $file, + 'relativePath' => $relativePath, + ]; + } + + $expiresAt = match ($this->expiration) { + '1h' => now()->addHour(), + '24h' => now()->addDay(), + '48h' => now()->addDays(2), + '7d' => now()->addWeek(), + '14d' => now()->addDays(14), + '30d' => now()->addMonth(), + default => null, + }; + + $share = $shareService->createShare($fileData, [ + 'password' => $this->usePassword ? $this->password : null, + 'expires_at' => $expiresAt, + 'max_downloads' => $this->maxDownloads ?: null, + ]); + + $this->redirect(route('share.created', $share), navigate: true); + } + + public function render(): mixed + { + $shareService = app(ShareService::class); + + return view('livewire.file-uploader', [ + 'isStorageFull' => $shareService->isStorageFull(), + 'siteTitle' => Setting::get('site_title'), + 'siteDescription' => Setting::get('site_description'), + 'siteLogo' => Setting::get('site_logo'), + 'allowNeverExpire' => (bool) Setting::get('allow_never_expire', false), + ]); + } +} diff --git a/app/Livewire/SetupWizard.php b/app/Livewire/SetupWizard.php new file mode 100644 index 0000000..e603c4a --- /dev/null +++ b/app/Livewire/SetupWizard.php @@ -0,0 +1,65 @@ +where('is_admin', true)->exists()) { + $this->redirect(route('upload'), navigate: true); + } + } + + public function createAdmin(): void + { + if (User::query()->where('is_admin', true)->exists()) { + $this->redirect(route('upload'), navigate: true); + + return; + } + + $this->validate(); + + $user = User::query()->create([ + 'name' => $this->name, + 'email' => $this->email, + 'password' => Hash::make($this->password), + 'email_verified_at' => now(), + ]); + + $user->is_admin = true; + $user->save(); + + Setting::set('setup_complete', 'true'); + + Auth::login($user); + + $this->redirect(route('admin.dashboard'), navigate: true); + } + + public function render(): mixed + { + return view('livewire.setup-wizard'); + } +} diff --git a/app/Livewire/ShareCreated.php b/app/Livewire/ShareCreated.php new file mode 100644 index 0000000..36cc561 --- /dev/null +++ b/app/Livewire/ShareCreated.php @@ -0,0 +1,23 @@ +share = $share; + } + + public function render(): mixed + { + return view('livewire.share-created'); + } +} diff --git a/app/Livewire/ShareDownload.php b/app/Livewire/ShareDownload.php new file mode 100644 index 0000000..a9d916d --- /dev/null +++ b/app/Livewire/ShareDownload.php @@ -0,0 +1,75 @@ +share = $share->load('files'); + + if ($share->isExpired() || $share->hasReachedDownloadLimit()) { + abort(404); + } + + if (! $share->isPasswordProtected()) { + $this->authenticated = true; + } + + if ($share->isPasswordProtected() && session('share_key_'.$share->token)) { + $this->authenticated = true; + } + } + + public function verifyPassword(ShareService $shareService): void + { + $rateLimitKey = 'share-password:'.$this->share->token.'|'.request()->ip(); + + if (RateLimiter::tooManyAttempts($rateLimitKey, 5)) { + $seconds = RateLimiter::availableIn($rateLimitKey); + $this->addError('password', __('Too many attempts. Please try again in :seconds seconds.', ['seconds' => $seconds])); + + return; + } + + $this->validate(); + + if (! $shareService->verifyPassword($this->share, $this->password)) { + RateLimiter::hit($rateLimitKey, 60); + $this->addError('password', __('The password is incorrect.')); + + return; + } + + RateLimiter::clear($rateLimitKey); + + $encryptionKey = $shareService->getDecryptionKey($this->share, $this->password); + session(['share_key_'.$this->share->token => $encryptionKey]); + $this->authenticated = true; + } + + public function render(): mixed + { + return view('livewire.share-download', [ + 'siteTitle' => Setting::get('site_title'), + 'siteDescription' => Setting::get('site_description'), + 'siteLogo' => Setting::get('site_logo'), + ]); + } +} diff --git a/app/Livewire/SystemPasswordPrompt.php b/app/Livewire/SystemPasswordPrompt.php new file mode 100644 index 0000000..29bdf34 --- /dev/null +++ b/app/Livewire/SystemPasswordPrompt.php @@ -0,0 +1,38 @@ +validate(); + + $systemPassword = Setting::get('system_password'); + + if (! $systemPassword || ! Hash::check($this->password, $systemPassword)) { + $this->addError('password', __('The password is incorrect.')); + + return; + } + + session(['system_password_verified' => true]); + + $this->redirect(route('upload'), navigate: true); + } + + public function render(): mixed + { + return view('livewire.system-password-prompt'); + } +} diff --git a/app/Models/Setting.php b/app/Models/Setting.php new file mode 100644 index 0000000..5291927 --- /dev/null +++ b/app/Models/Setting.php @@ -0,0 +1,28 @@ +where('key', $key)->first(); + + return $setting ? $setting->value : $default; + } + + public static function set(string $key, mixed $value): void + { + static::query()->updateOrCreate( + ['key' => $key], + ['value' => $value], + ); + } +} diff --git a/app/Models/Share.php b/app/Models/Share.php new file mode 100644 index 0000000..85261c2 --- /dev/null +++ b/app/Models/Share.php @@ -0,0 +1,65 @@ + + */ + protected function casts(): array + { + return [ + 'expires_at' => 'datetime', + 'max_downloads' => 'integer', + 'download_count' => 'integer', + 'total_size' => 'integer', + 'encryption_key' => 'encrypted', + ]; + } + + /** + * @return HasMany + */ + public function files(): HasMany + { + return $this->hasMany(ShareFile::class); + } + + public function isExpired(): bool + { + return $this->expires_at && $this->expires_at->isPast(); + } + + public function isPasswordProtected(): bool + { + return ! is_null($this->password); + } + + public function hasReachedDownloadLimit(): bool + { + return $this->max_downloads && $this->download_count >= $this->max_downloads; + } + + public function getRouteKeyName(): string + { + return 'token'; + } +} diff --git a/app/Models/ShareFile.php b/app/Models/ShareFile.php new file mode 100644 index 0000000..eb9256e --- /dev/null +++ b/app/Models/ShareFile.php @@ -0,0 +1,39 @@ + + */ + protected function casts(): array + { + return [ + 'file_size' => 'integer', + ]; + } + + /** + * @return BelongsTo + */ + public function share(): BelongsTo + { + return $this->belongsTo(Share::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 214bea4..832c364 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -47,6 +47,7 @@ class User extends Authenticatable return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'is_admin' => 'boolean', ]; } diff --git a/app/Services/FileEncryptionService.php b/app/Services/FileEncryptionService.php new file mode 100644 index 0000000..25afb6d --- /dev/null +++ b/app/Services/FileEncryptionService.php @@ -0,0 +1,355 @@ +normalizeToBinaryKey($key); + $baseNonce = random_bytes(self::NONCE_LENGTH); + $chunkSize = self::DEFAULT_CHUNK_SIZE; + + // Write header + fwrite($dest, self::MAGIC_HEADER); + fwrite($dest, pack('N', $chunkSize)); + fwrite($dest, $baseNonce); + + $chunkIndex = 0; + + while (! feof($source)) { + $plaintext = fread($source, $chunkSize); + + if ($plaintext === false || $plaintext === '') { + break; + } + + $nonce = $this->deriveChunkNonce($baseNonce, $chunkIndex); + $tag = ''; + + $ciphertext = openssl_encrypt( + $plaintext, + self::CIPHER, + $binaryKey, + OPENSSL_RAW_DATA, + $nonce, + $tag, + '', + self::TAG_LENGTH, + ); + + if ($ciphertext === false) { + throw new RuntimeException('Encryption failed at chunk '.$chunkIndex); + } + + fwrite($dest, $tag); + fwrite($dest, $ciphertext); + $chunkIndex++; + } + } catch (RuntimeException $e) { + fclose($source); + fclose($dest); + @unlink($destPath); + + throw $e; + } + + fclose($source); + fclose($dest); + } + + /** + * Decrypt a file and return the plaintext content. + */ + public function decryptFile(string $encryptedPath, string $key): string + { + if ($this->isChunkedFormat($encryptedPath)) { + $parts = []; + + foreach ($this->decryptChunks($encryptedPath, $key) as $chunk) { + $parts[] = $chunk; + } + + return implode('', $parts); + } + + return $this->decryptLegacy($encryptedPath, $key); + } + + /** + * Decrypt a file and stream the response. + */ + public function decryptFileStream(string $encryptedPath, string $key, string $filename, string $mimeType, ?int $fileSize = null): StreamedResponse + { + $headers = [ + 'Content-Type' => $mimeType ?: 'application/octet-stream', + 'Content-Disposition' => HeaderUtils::makeDisposition('attachment', $filename, 'download'), + ]; + + if ($fileSize !== null) { + $headers['Content-Length'] = $fileSize; + } + + if ($this->isChunkedFormat($encryptedPath)) { + return new StreamedResponse(function () use ($encryptedPath, $key): void { + foreach ($this->decryptChunks($encryptedPath, $key) as $chunk) { + echo $chunk; + flush(); + } + }, 200, $headers); + } + + $content = $this->decryptLegacy($encryptedPath, $key); + + if (! isset($headers['Content-Length'])) { + $headers['Content-Length'] = strlen($content); + } + + return new StreamedResponse(function () use ($content): void { + echo $content; + }, 200, $headers); + } + + /** + * Return a closure that decrypts a file into a temporary stream resource. + * Suitable for ZipStream's addFileFromCallback. + */ + public function decryptFileToCallback(string $encryptedPath, string $key): Closure + { + return function () use ($encryptedPath, $key) { + $tmp = tmpfile(); + + if ($tmp === false) { + throw new RuntimeException('Cannot create temporary file'); + } + + if ($this->isChunkedFormat($encryptedPath)) { + foreach ($this->decryptChunks($encryptedPath, $key) as $chunk) { + fwrite($tmp, $chunk); + } + } else { + fwrite($tmp, $this->decryptLegacy($encryptedPath, $key)); + } + + rewind($tmp); + + return $tmp; + }; + } + + /** + * Normalize a hex key to binary. + */ + private function normalizeToBinaryKey(string $key): string + { + return strlen($key) === 64 ? hex2bin($key) : $key; + } + + /** + * Derive a unique nonce for a chunk by XORing the chunk index into the last 4 bytes. + */ + private function deriveChunkNonce(string $baseNonce, int $chunkIndex): string + { + $nonce = $baseNonce; + $indexBytes = pack('N', $chunkIndex); + + for ($i = 0; $i < 4; $i++) { + $nonce[self::NONCE_LENGTH - 4 + $i] = $nonce[self::NONCE_LENGTH - 4 + $i] ^ $indexBytes[$i]; + } + + return $nonce; + } + + /** + * Check if a file uses the chunked encryption format. + */ + private function isChunkedFormat(string $path): bool + { + $handle = fopen($path, 'rb'); + + if ($handle === false) { + return false; + } + + $magic = fread($handle, 8); + fclose($handle); + + return $magic === self::MAGIC_HEADER; + } + + /** + * Decrypt a legacy single-block encrypted file. + * Format: [12-byte nonce][16-byte auth tag][ciphertext] + */ + private function decryptLegacy(string $encryptedPath, string $key): string + { + $data = file_get_contents($encryptedPath); + + if ($data === false) { + throw new RuntimeException("Cannot read encrypted file: {$encryptedPath}"); + } + + $binaryKey = $this->normalizeToBinaryKey($key); + $nonce = substr($data, 0, self::NONCE_LENGTH); + $tag = substr($data, self::NONCE_LENGTH, self::TAG_LENGTH); + $ciphertext = substr($data, self::NONCE_LENGTH + self::TAG_LENGTH); + + $plaintext = openssl_decrypt( + $ciphertext, + self::CIPHER, + $binaryKey, + OPENSSL_RAW_DATA, + $nonce, + $tag, + ); + + if ($plaintext === false) { + throw new RuntimeException('Decryption failed - wrong key or corrupted data'); + } + + return $plaintext; + } + + /** + * Generator that yields decrypted plaintext chunks from a chunked encrypted file. + * + * @return Generator + */ + private function decryptChunks(string $encryptedPath, string $key): Generator + { + $handle = fopen($encryptedPath, 'rb'); + + if ($handle === false) { + throw new RuntimeException("Cannot read encrypted file: {$encryptedPath}"); + } + + try { + // Read header + $magic = fread($handle, 8); + + if ($magic !== self::MAGIC_HEADER) { + throw new RuntimeException('Invalid chunked file format'); + } + + $chunkSizeData = fread($handle, 4); + $chunkSize = unpack('N', $chunkSizeData)[1]; + + $baseNonce = fread($handle, self::NONCE_LENGTH); + + if (strlen($baseNonce) !== self::NONCE_LENGTH) { + throw new RuntimeException('Invalid chunked file: truncated header'); + } + + $binaryKey = $this->normalizeToBinaryKey($key); + $chunkIndex = 0; + + while (! feof($handle)) { + $tag = fread($handle, self::TAG_LENGTH); + + if ($tag === false || strlen($tag) === 0) { + break; + } + + if (strlen($tag) !== self::TAG_LENGTH) { + throw new RuntimeException('Invalid chunked file: truncated tag at chunk '.$chunkIndex); + } + + $ciphertext = fread($handle, $chunkSize); + + if ($ciphertext === false || $ciphertext === '') { + throw new RuntimeException('Invalid chunked file: missing ciphertext at chunk '.$chunkIndex); + } + + $nonce = $this->deriveChunkNonce($baseNonce, $chunkIndex); + + $plaintext = openssl_decrypt( + $ciphertext, + self::CIPHER, + $binaryKey, + OPENSSL_RAW_DATA, + $nonce, + $tag, + ); + + if ($plaintext === false) { + throw new RuntimeException('Decryption failed - wrong key or corrupted data'); + } + + yield $plaintext; + $chunkIndex++; + } + } finally { + fclose($handle); + } + } +} diff --git a/app/Services/ShareService.php b/app/Services/ShareService.php new file mode 100644 index 0000000..4492920 --- /dev/null +++ b/app/Services/ShareService.php @@ -0,0 +1,175 @@ + $files + * @param array{password?: string|null, expires_at?: string|null, max_downloads?: int|null} $options + */ + public function createShare(array $files, array $options = []): Share + { + $token = $this->generateUniqueToken(); + $salt = $this->encryptionService->generateSalt(); + $password = $options['password'] ?? null; + + if ($password) { + $encryptionKey = $this->encryptionService->deriveKey($password, $salt); + $encryptionKeyHex = bin2hex($encryptionKey); + $storedEncryptionKey = null; + } else { + $encryptionKeyHex = $this->encryptionService->generateRandomKey(); + $storedEncryptionKey = $encryptionKeyHex; + } + + $share = Share::query()->create([ + 'token' => $token, + 'password' => $password ? Hash::make($password) : null, + 'encryption_key' => $storedEncryptionKey, + 'encryption_salt' => $salt, + 'expires_at' => $options['expires_at'] ?? null, + 'max_downloads' => $options['max_downloads'] ?? null, + 'total_size' => 0, + ]); + + $totalSize = 0; + + foreach ($files as $fileData) { + /** @var UploadedFile $file */ + $file = $fileData['file']; + $relativePath = $fileData['relativePath'] ?? null; + $storedName = Str::uuid().'.enc'; + $storedPath = 'shares/'.$share->token.'/'.$storedName; + + $tempPath = $file->getRealPath(); + $destPath = Storage::disk('shares')->path($share->token.'/'.$storedName); + + Storage::disk('shares')->makeDirectory($share->token); + + $this->encryptionService->encryptFile($tempPath, $destPath, $encryptionKeyHex); + + ShareFile::query()->create([ + 'share_id' => $share->id, + 'original_name' => $file->getClientOriginalName(), + 'relative_path' => $relativePath, + 'stored_path' => $storedPath, + 'file_size' => $file->getSize(), + 'mime_type' => $file->getMimeType(), + ]); + + $totalSize += $file->getSize(); + } + + $share->update(['total_size' => $totalSize]); + + return $share->fresh(); + } + + /** + * Generate a unique share token with retry on collision. + */ + private function generateUniqueToken(): string + { + for ($i = 0; $i < 5; $i++) { + $token = Str::random(16); + + if (! Share::query()->where('token', $token)->exists()) { + return $token; + } + } + + throw new RuntimeException('Unable to generate a unique share token'); + } + + /** + * Delete a share and its files from disk. + */ + public function deleteShare(Share $share): void + { + Storage::disk('shares')->deleteDirectory($share->token); + + $share->delete(); + } + + /** + * Get the decryption key for a share. + */ + public function getDecryptionKey(Share $share, ?string $password = null): string + { + if ($share->isPasswordProtected()) { + if (! $password) { + throw new \RuntimeException('Password required for this share'); + } + + return bin2hex($this->encryptionService->deriveKey($password, $share->encryption_salt)); + } + + return $share->encryption_key; + } + + /** + * Verify a password against a share's stored hash. + */ + public function verifyPassword(Share $share, string $password): bool + { + if (! $share->isPasswordProtected()) { + return true; + } + + return Hash::check($password, $share->password); + } + + /** + * Record a download and auto-delete if limit reached. + */ + public function recordDownload(Share $share): void + { + $share->increment('download_count'); + + if ($share->hasReachedDownloadLimit()) { + $this->deleteShare($share); + } + } + + /** + * Get total used space in bytes. + */ + public function getTotalUsedSpace(): int + { + return (int) Share::query()->sum('total_size'); + } + + /** + * Check if storage is full based on admin-configured max quota. + */ + public function isStorageFull(): bool + { + $maxQuota = (int) Setting::get('max_storage_quota', 20 * 1024 * 1024 * 1024); + + return $this->getTotalUsedSpace() >= $maxQuota; + } + + /** + * Get the maximum storage quota in bytes. + */ + public function getMaxStorageQuota(): int + { + return (int) Setting::get('max_storage_quota', 20 * 1024 * 1024 * 1024); + } +} diff --git a/boost.json b/boost.json index 26ba353..146078a 100644 --- a/boost.json +++ b/boost.json @@ -10,9 +10,9 @@ ], "sail": false, "skills": [ - "fluxui-development", "livewire-development", "pest-testing", + "tailwindcss-development", "developing-with-fortify" ] } diff --git a/bootstrap/app.php b/bootstrap/app.php index c183276..c6e66e7 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,9 @@ withMiddleware(function (Middleware $middleware): void { - // + $middleware->trustProxies(at: '*'); + + $middleware->web(append: [ + EnsureSetupComplete::class, + SecurityHeaders::class, + ]); + + $middleware->alias([ + 'system.password' => SystemPasswordGate::class, + 'admin' => EnsureAdmin::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/composer.json b/composer.json index f01e5d7..5fde80b 100644 --- a/composer.json +++ b/composer.json @@ -9,12 +9,14 @@ ], "license": "MIT", "require": { - "php": "^8.2", + "php": "^8.5", "laravel/fortify": "^1.30", "laravel/framework": "^12.0", + "laravel/octane": "^2.13", "laravel/tinker": "^2.10.1", - "livewire/flux": "^2.9.0", - "livewire/livewire": "^4.0" + "livewire/livewire": "^4.0", + "maennchen/zipstream-php": "^3.2", + "robsontenorio/mary": "^2.7" }, "require-dev": { "fakerphp/faker": "^1.23", @@ -50,7 +52,7 @@ ], "dev": [ "Composer\\Config::disableProcessTimeout", - "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others" + "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan octane:frankenphp --host=127.0.0.1 --port=8000 --watch\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others" ], "lint": [ "pint --parallel" @@ -99,4 +101,4 @@ }, "minimum-stability": "stable", "prefer-stable": true -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index 5f926c1..0deea70 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "35f5019ba09421751a32b65d0a7fbbee", + "content-hash": "6b7f78bec30d422ca6805f3c503bd185", "packages": [ { "name": "bacon/bacon-qr-code", @@ -61,6 +61,156 @@ }, "time": "2025-11-19T17:15:36+00:00" }, + { + "name": "blade-ui-kit/blade-heroicons", + "version": "2.6.0", + "source": { + "type": "git", + "url": "https://github.com/driesvints/blade-heroicons.git", + "reference": "4553b2a1f6c76f0ac7f3bc0de4c0cfa06a097d19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/driesvints/blade-heroicons/zipball/4553b2a1f6c76f0ac7f3bc0de4c0cfa06a097d19", + "reference": "4553b2a1f6c76f0ac7f3bc0de4c0cfa06a097d19", + "shasum": "" + }, + "require": { + "blade-ui-kit/blade-icons": "^1.6", + "illuminate/support": "^9.0|^10.0|^11.0|^12.0", + "php": "^8.0" + }, + "require-dev": { + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", + "phpunit/phpunit": "^9.0|^10.5|^11.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "BladeUI\\Heroicons\\BladeHeroiconsServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "BladeUI\\Heroicons\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dries Vints", + "homepage": "https://driesvints.com" + } + ], + "description": "A package to easily make use of Heroicons in your Laravel Blade views.", + "homepage": "https://github.com/blade-ui-kit/blade-heroicons", + "keywords": [ + "Heroicons", + "blade", + "laravel" + ], + "support": { + "issues": "https://github.com/driesvints/blade-heroicons/issues", + "source": "https://github.com/driesvints/blade-heroicons/tree/2.6.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/driesvints", + "type": "github" + }, + { + "url": "https://www.paypal.com/paypalme/driesvints", + "type": "paypal" + } + ], + "time": "2025-02-13T20:53:33+00:00" + }, + { + "name": "blade-ui-kit/blade-icons", + "version": "1.8.1", + "source": { + "type": "git", + "url": "https://github.com/driesvints/blade-icons.git", + "reference": "47e7b6f43250e6404e4224db8229219cd42b543c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/driesvints/blade-icons/zipball/47e7b6f43250e6404e4224db8229219cd42b543c", + "reference": "47e7b6f43250e6404e4224db8229219cd42b543c", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/filesystem": "^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/view": "^8.0|^9.0|^10.0|^11.0|^12.0", + "php": "^7.4|^8.0", + "symfony/console": "^5.3|^6.0|^7.0", + "symfony/finder": "^5.3|^6.0|^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.5.1", + "orchestra/testbench": "^6.0|^7.0|^8.0|^9.0|^10.0", + "phpunit/phpunit": "^9.0|^10.5|^11.0" + }, + "bin": [ + "bin/blade-icons-generate" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "BladeUI\\Icons\\BladeIconsServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "BladeUI\\Icons\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dries Vints", + "homepage": "https://driesvints.com" + } + ], + "description": "A package to easily make use of icons in your Laravel Blade views.", + "homepage": "https://github.com/driesvints/blade-icons", + "keywords": [ + "blade", + "icons", + "laravel", + "svg" + ], + "support": { + "issues": "https://github.com/driesvints/blade-icons/issues", + "source": "https://github.com/driesvints/blade-icons" + }, + "funding": [ + { + "url": "https://github.com/sponsors/driesvints", + "type": "github" + }, + { + "url": "https://www.paypal.com/paypalme/driesvints", + "type": "paypal" + } + ], + "time": "2026-01-20T09:46:32+00:00" + }, { "name": "brick/math", "version": "0.14.8", @@ -1157,6 +1307,331 @@ ], "time": "2025-08-22T14:27:06+00:00" }, + { + "name": "jfcherng/php-color-output", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/jfcherng/php-color-output.git", + "reference": "6c7bf16686cc6a291647fcb87491640a2d5edd20" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jfcherng/php-color-output/zipball/6c7bf16686cc6a291647fcb87491640a2d5edd20", + "reference": "6c7bf16686cc6a291647fcb87491640a2d5edd20", + "shasum": "" + }, + "require": { + "php": ">=7.1.3" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.19", + "liip/rmt": "^1.6", + "phan/phan": "^2 || ^3 || ^4", + "phpunit/phpunit": ">=7 <10", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Jfcherng\\Utility\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jack Cherng", + "email": "jfcherng@gmail.com" + } + ], + "description": "Make your PHP command-line application colorful.", + "keywords": [ + "ansi-colors", + "color", + "command-line", + "str-color" + ], + "support": { + "issues": "https://github.com/jfcherng/php-color-output/issues", + "source": "https://github.com/jfcherng/php-color-output/tree/3.0.0" + }, + "funding": [ + { + "url": "https://www.paypal.me/jfcherng/5usd", + "type": "custom" + } + ], + "time": "2021-05-27T02:45:54+00:00" + }, + { + "name": "jfcherng/php-diff", + "version": "6.16.2", + "source": { + "type": "git", + "url": "https://github.com/jfcherng/php-diff.git", + "reference": "7f46bcfc582e81769237d0b3f6b8a548efe8799d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jfcherng/php-diff/zipball/7f46bcfc582e81769237d0b3f6b8a548efe8799d", + "reference": "7f46bcfc582e81769237d0b3f6b8a548efe8799d", + "shasum": "" + }, + "require": { + "jfcherng/php-color-output": "^3", + "jfcherng/php-mb-string": "^1.4.6 || ^2", + "jfcherng/php-sequence-matcher": "^3.2.10 || ^4", + "php": ">=7.4" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.51", + "liip/rmt": "^1.6", + "phan/phan": "^5", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^3.6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Jfcherng\\Diff\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Jack Cherng", + "email": "jfcherng@gmail.com" + }, + { + "name": "Chris Boulton", + "email": "chris.boulton@interspire.com" + } + ], + "description": "A comprehensive library for generating differences between two strings in multiple formats (unified, side by side HTML etc).", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/jfcherng/php-diff/issues", + "source": "https://github.com/jfcherng/php-diff/tree/6.16.2" + }, + "funding": [ + { + "url": "https://www.paypal.me/jfcherng/5usd", + "type": "custom" + } + ], + "time": "2024-03-10T17:40:29+00:00" + }, + { + "name": "jfcherng/php-mb-string", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/jfcherng/php-mb-string.git", + "reference": "8407bfefde47849c9e7c9594e6de2ac85a0f845d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jfcherng/php-mb-string/zipball/8407bfefde47849c9e7c9594e6de2ac85a0f845d", + "reference": "8407bfefde47849c9e7c9594e6de2ac85a0f845d", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=8.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3", + "phan/phan": "^5", + "phpunit/phpunit": "^9 || ^10" + }, + "suggest": { + "ext-iconv": "Either \"ext-iconv\" or \"ext-mbstring\" is requried.", + "ext-mbstring": "Either \"ext-iconv\" or \"ext-mbstring\" is requried." + }, + "type": "library", + "autoload": { + "psr-4": { + "Jfcherng\\Utility\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jack Cherng", + "email": "jfcherng@gmail.com" + } + ], + "description": "A high performance multibytes sting implementation for frequently reading/writing operations.", + "support": { + "issues": "https://github.com/jfcherng/php-mb-string/issues", + "source": "https://github.com/jfcherng/php-mb-string/tree/2.0.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/jfcherng/5usd", + "type": "custom" + } + ], + "time": "2023-04-17T14:23:16+00:00" + }, + { + "name": "jfcherng/php-sequence-matcher", + "version": "4.0.3", + "source": { + "type": "git", + "url": "https://github.com/jfcherng/php-sequence-matcher.git", + "reference": "d2038ac29627340a7458609072a8ba355e80ec5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jfcherng/php-sequence-matcher/zipball/d2038ac29627340a7458609072a8ba355e80ec5b", + "reference": "d2038ac29627340a7458609072a8ba355e80ec5b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3", + "phan/phan": "^5", + "phpunit/phpunit": "^9 || ^10", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Jfcherng\\Diff\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Jack Cherng", + "email": "jfcherng@gmail.com" + }, + { + "name": "Chris Boulton", + "email": "chris.boulton@interspire.com" + } + ], + "description": "A longest sequence matcher. The logic is primarily based on the Python difflib package.", + "support": { + "issues": "https://github.com/jfcherng/php-sequence-matcher/issues", + "source": "https://github.com/jfcherng/php-sequence-matcher/tree/4.0.3" + }, + "funding": [ + { + "url": "https://www.paypal.me/jfcherng/5usd", + "type": "custom" + } + ], + "time": "2023-05-21T07:57:08+00:00" + }, + { + "name": "laminas/laminas-diactoros", + "version": "3.8.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-diactoros.git", + "reference": "60c182916b2749480895601649563970f3f12ec4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-diactoros/zipball/60c182916b2749480895601649563970f3f12ec4", + "reference": "60c182916b2749480895601649563970f3f12ec4", + "shasum": "" + }, + "require": { + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "conflict": { + "amphp/amp": "<2.6.4" + }, + "provide": { + "psr/http-factory-implementation": "^1.0", + "psr/http-message-implementation": "^1.1 || ^2.0" + }, + "require-dev": { + "ext-curl": "*", + "ext-dom": "*", + "ext-gd": "*", + "ext-libxml": "*", + "http-interop/http-factory-tests": "^2.2.0", + "laminas/laminas-coding-standard": "~3.1.0", + "php-http/psr7-integration-tests": "^1.4.0", + "phpunit/phpunit": "^10.5.36", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.13" + }, + "type": "library", + "extra": { + "laminas": { + "module": "Laminas\\Diactoros", + "config-provider": "Laminas\\Diactoros\\ConfigProvider" + } + }, + "autoload": { + "files": [ + "src/functions/create_uploaded_file.php", + "src/functions/marshal_headers_from_sapi.php", + "src/functions/marshal_method_from_sapi.php", + "src/functions/marshal_protocol_version_from_sapi.php", + "src/functions/normalize_server.php", + "src/functions/normalize_uploaded_files.php", + "src/functions/parse_cookie_header.php" + ], + "psr-4": { + "Laminas\\Diactoros\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "PSR HTTP Message implementations", + "homepage": "https://laminas.dev", + "keywords": [ + "http", + "laminas", + "psr", + "psr-17", + "psr-7" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-diactoros/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-diactoros/issues", + "rss": "https://github.com/laminas/laminas-diactoros/releases.atom", + "source": "https://github.com/laminas/laminas-diactoros" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2025-10-12T15:31:36+00:00" + }, { "name": "laravel/fortify", "version": "v1.34.1", @@ -1442,6 +1917,96 @@ }, "time": "2026-02-10T18:20:19+00:00" }, + { + "name": "laravel/octane", + "version": "v2.13.5", + "source": { + "type": "git", + "url": "https://github.com/laravel/octane.git", + "reference": "c343716659c280a7613a0c10d3241215512355ee" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/octane/zipball/c343716659c280a7613a0c10d3241215512355ee", + "reference": "c343716659c280a7613a0c10d3241215512355ee", + "shasum": "" + }, + "require": { + "laminas/laminas-diactoros": "^3.0", + "laravel/framework": "^10.10.1|^11.0|^12.0", + "laravel/prompts": "^0.1.24|^0.2.0|^0.3.0", + "laravel/serializable-closure": "^1.3|^2.0", + "nesbot/carbon": "^2.66.0|^3.0", + "php": "^8.1.0", + "symfony/console": "^6.0|^7.0", + "symfony/psr-http-message-bridge": "^2.2.0|^6.4|^7.0" + }, + "conflict": { + "spiral/roadrunner": "<2023.1.0", + "spiral/roadrunner-cli": "<2.6.0", + "spiral/roadrunner-http": "<3.3.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.6.1", + "inertiajs/inertia-laravel": "^1.3.2|^2.0", + "laravel/scout": "^10.2.1", + "laravel/socialite": "^5.6.1", + "livewire/livewire": "^2.12.3|^3.0", + "mockery/mockery": "^1.5.1", + "nunomaduro/collision": "^6.4.0|^7.5.2|^8.0", + "orchestra/testbench": "^8.21|^9.0|^10.0", + "phpstan/phpstan": "^2.1.7", + "phpunit/phpunit": "^10.4|^11.5", + "spiral/roadrunner-cli": "^2.6.0", + "spiral/roadrunner-http": "^3.3.0" + }, + "bin": [ + "bin/roadrunner-worker", + "bin/swoole-server" + ], + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Octane": "Laravel\\Octane\\Facades\\Octane" + }, + "providers": [ + "Laravel\\Octane\\OctaneServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Octane\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Supercharge your Laravel application's performance.", + "keywords": [ + "frankenphp", + "laravel", + "octane", + "roadrunner", + "swoole" + ], + "support": { + "issues": "https://github.com/laravel/octane/issues", + "source": "https://github.com/laravel/octane" + }, + "time": "2026-01-22T17:24:46+00:00" + }, { "name": "laravel/prompts", "version": "v0.3.13", @@ -2187,72 +2752,6 @@ ], "time": "2026-01-15T06:54:53+00:00" }, - { - "name": "livewire/flux", - "version": "v2.12.0", - "source": { - "type": "git", - "url": "https://github.com/livewire/flux.git", - "reference": "78bc26f54a29c28ff916751b9f796f4ce1592003" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/livewire/flux/zipball/78bc26f54a29c28ff916751b9f796f4ce1592003", - "reference": "78bc26f54a29c28ff916751b9f796f4ce1592003", - "shasum": "" - }, - "require": { - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "illuminate/view": "^10.0|^11.0|^12.0", - "laravel/prompts": "^0.1|^0.2|^0.3", - "livewire/livewire": "^3.7.4|^4.0", - "php": "^8.1", - "symfony/console": "^6.0|^7.0" - }, - "conflict": { - "livewire/blaze": "<1.0.0" - }, - "type": "library", - "extra": { - "laravel": { - "aliases": { - "Flux": "Flux\\Flux" - }, - "providers": [ - "Flux\\FluxServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Flux\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "proprietary" - ], - "authors": [ - { - "name": "Caleb Porzio", - "email": "calebporzio@gmail.com" - } - ], - "description": "The official UI component library for Livewire.", - "keywords": [ - "components", - "flux", - "laravel", - "livewire", - "ui" - ], - "support": { - "issues": "https://github.com/livewire/flux/issues", - "source": "https://github.com/livewire/flux/tree/v2.12.0" - }, - "time": "2026-02-09T23:35:27+00:00" - }, { "name": "livewire/livewire", "version": "v4.1.4", @@ -2329,6 +2828,84 @@ ], "time": "2026-02-09T22:59:54+00:00" }, + { + "name": "maennchen/zipstream-php", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5", + "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.3" + }, + "require-dev": { + "brianium/paratest": "^7.7", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.86", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^12.0", + "vimeo/psalm": "^6.0" + }, + "suggest": { + "guzzlehttp/psr7": "^2.4", + "psr/http-message": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + } + ], + "time": "2025-12-10T09:58:31+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -3721,6 +4298,92 @@ }, "time": "2025-12-14T04:43:48+00:00" }, + { + "name": "robsontenorio/mary", + "version": "2.7.0", + "source": { + "type": "git", + "url": "https://github.com/robsontenorio/mary.git", + "reference": "2657244f907eec450853a093274a8c763c8fb6fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/robsontenorio/mary/zipball/2657244f907eec450853a093274a8c763c8fb6fa", + "reference": "2657244f907eec450853a093274a8c763c8fb6fa", + "shasum": "" + }, + "require": { + "blade-ui-kit/blade-heroicons": "^2.6", + "illuminate/support": "^10.0|^11.0|^12.0", + "jfcherng/php-diff": "^6.15", + "laravel/prompts": "^0|^1" + }, + "require-dev": { + "orchestra/testbench": "^8|^9|^10", + "phpunit/phpunit": "^10|^11" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mary": "Mary\\Facades\\Mary" + }, + "providers": [ + "Mary\\MaryServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Mary\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Robson Tenório", + "email": "rrtenorio@gmail.com", + "homepage": "https://github.com/robsontenorio" + } + ], + "description": "Gorgeous UI components for Livewire powered by daisyUI and Tailwind", + "homepage": "https://mary-ui.com", + "keywords": [ + "DaisyUI", + "alpinejs", + "blade", + "blade ui components", + "components", + "laravel", + "livewire", + "livewire components", + "livewire packages", + "livewire ui", + "livewire ui components", + "livewire-components", + "livewire-packages", + "tailwind", + "tallstack", + "tallstack components", + "tallstack ui", + "tallstackui", + "ui" + ], + "support": { + "issues": "https://github.com/robsontenorio/mary/issues", + "source": "https://github.com/robsontenorio/mary/tree/2.7.0" + }, + "funding": [ + { + "url": "https://github.com/robsontenorio", + "type": "github" + } + ], + "time": "2026-02-07T20:44:14+00:00" + }, { "name": "symfony/clock", "version": "v8.0.0", @@ -5611,6 +6274,94 @@ ], "time": "2026-01-26T15:07:59+00:00" }, + { + "name": "symfony/psr-http-message-bridge", + "version": "v7.4.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/psr-http-message-bridge.git", + "reference": "929ffe10bbfbb92e711ac3818d416f9daffee067" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/929ffe10bbfbb92e711ac3818d416f9daffee067", + "reference": "929ffe10bbfbb92e711ac3818d416f9daffee067", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/http-message": "^1.0|^2.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0" + }, + "conflict": { + "php-http/discovery": "<1.15", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "nyholm/psr7": "^1.1", + "php-http/discovery": "^1.15", + "psr/log": "^1.1.4|^2|^3", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4.13|^7.1.6|^8.0", + "symfony/http-kernel": "^6.4.13|^7.1.6|^8.0", + "symfony/runtime": "^6.4.13|^7.1.6|^8.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\PsrHttpMessage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "PSR HTTP message bridge", + "homepage": "https://symfony.com", + "keywords": [ + "http", + "http-message", + "psr-17", + "psr-7" + ], + "support": { + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.4.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-03T23:30:35+00:00" + }, { "name": "symfony/routing", "version": "v7.4.4", @@ -9982,7 +10733,7 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.2" + "php": "^8.5" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/config/app.php b/config/app.php index 423eed5..ba7f372 100644 --- a/config/app.php +++ b/config/app.php @@ -13,7 +13,7 @@ return [ | */ - 'name' => env('APP_NAME', 'Laravel'), + 'name' => env('APP_NAME', 'SealShare'), /* |-------------------------------------------------------------------------- diff --git a/config/filesystems.php b/config/filesystems.php index 37d8fca..51005ac 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -47,6 +47,13 @@ return [ 'report' => false, ], + 'shares' => [ + 'driver' => 'local', + 'root' => storage_path('app/shares'), + 'throw' => false, + 'report' => false, + ], + 's3' => [ 'driver' => 's3', 'key' => env('AWS_ACCESS_KEY_ID'), diff --git a/config/fortify.php b/config/fortify.php index ce67e2c..cad446f 100644 --- a/config/fortify.php +++ b/config/fortify.php @@ -144,7 +144,7 @@ return [ */ 'features' => [ - Features::registration(), + // Features::registration(), // Disabled - admin created via setup wizard Features::resetPasswords(), Features::emailVerification(), Features::twoFactorAuthentication([ diff --git a/config/livewire.php b/config/livewire.php new file mode 100644 index 0000000..6767b6e --- /dev/null +++ b/config/livewire.php @@ -0,0 +1,282 @@ + [ + resource_path('views/components'), + resource_path('views/livewire'), + ], + + /* + |--------------------------------------------------------------------------- + | Component Namespaces + |--------------------------------------------------------------------------- + | + | This value sets default namespaces that will be used to resolve view-based + | components like single-file and multi-file components. These folders'll + | also be referenced when creating new components via the make command. + | + */ + + 'component_namespaces' => [ + 'layouts' => resource_path('views/layouts'), + 'pages' => resource_path('views/pages'), + ], + + /* + |--------------------------------------------------------------------------- + | Page Layout + |--------------------------------------------------------------------------- + | The view that will be used as the layout when rendering a single component as + | an entire page via `Route::livewire('/post/create', 'pages::create-post')`. + | In this case, the content of pages::create-post will render into $slot. + | + */ + + 'component_layout' => 'layouts::app', + + /* + |--------------------------------------------------------------------------- + | Lazy Loading Placeholder + |--------------------------------------------------------------------------- + | Livewire allows you to lazy load components that would otherwise slow down + | the initial page load. Every component can have a custom placeholder or + | you can define the default placeholder view for all components below. + | + */ + + 'component_placeholder' => null, // Example: 'placeholders::skeleton' + + /* + |--------------------------------------------------------------------------- + | Make Command + |--------------------------------------------------------------------------- + | This value determines the default configuration for the artisan make command + | You can configure the component type (sfc, mfc, class) and whether to use + | the high-voltage (⚡) emoji as a prefix in the sfc|mfc component names. + | + */ + + 'make_command' => [ + 'type' => 'sfc', // Options: 'sfc', 'mfc', 'class' + 'emoji' => true, // Options: true, false + 'with' => [ + 'js' => false, + 'css' => false, + 'test' => false, + ], + ], + + /* + |--------------------------------------------------------------------------- + | Class Namespace + |--------------------------------------------------------------------------- + | + | This value sets the root class namespace for Livewire component classes in + | your application. This value will change where component auto-discovery + | finds components. It's also referenced by the file creation commands. + | + */ + + 'class_namespace' => 'App\\Livewire', + + /* + |--------------------------------------------------------------------------- + | Class Path + |--------------------------------------------------------------------------- + | + | This value is used to specify the path where Livewire component class files + | are created when running creation commands like `artisan make:livewire`. + | This path is customizable to match your projects directory structure. + | + */ + + 'class_path' => app_path('Livewire'), + + /* + |--------------------------------------------------------------------------- + | View Path + |--------------------------------------------------------------------------- + | + | This value is used to specify where Livewire component Blade templates are + | stored when running file creation commands like `artisan make:livewire`. + | It is also used if you choose to omit a component's render() method. + | + */ + + 'view_path' => resource_path('views/livewire'), + + /* + |--------------------------------------------------------------------------- + | Temporary File Uploads + |--------------------------------------------------------------------------- + | + | Livewire handles file uploads by storing uploads in a temporary directory + | before the file is stored permanently. All file uploads are directed to + | a global endpoint for temporary storage. You may configure this below: + | + */ + + 'temporary_file_upload' => [ + 'disk' => env('LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK'), // Example: 'local', 's3' | Default: 'default' + 'rules' => ['required', 'file', 'max:4194304'], // 4GB — fine-grained limits enforced per-component + 'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp' + 'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1' + 'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs... + 'png', 'gif', 'bmp', 'svg', 'wav', 'mp4', + 'mov', 'avi', 'wmv', 'mp3', 'm4a', + 'jpg', 'jpeg', 'mpga', 'webp', 'wma', + ], + 'max_upload_time' => 30, // Max duration (in minutes) before an upload is invalidated... + 'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs... + ], + + /* + |--------------------------------------------------------------------------- + | Render On Redirect + |--------------------------------------------------------------------------- + | + | This value determines if Livewire will run a component's `render()` method + | after a redirect has been triggered using something like `redirect(...)` + | Setting this to true will render the view once more before redirecting + | + */ + + 'render_on_redirect' => false, + + /* + |--------------------------------------------------------------------------- + | Eloquent Model Binding + |--------------------------------------------------------------------------- + | + | Previous versions of Livewire supported binding directly to eloquent model + | properties using wire:model by default. However, this behavior has been + | deemed too "magical" and has therefore been put under a feature flag. + | + */ + + 'legacy_model_binding' => false, + + /* + |--------------------------------------------------------------------------- + | Auto-inject Frontend Assets + |--------------------------------------------------------------------------- + | + | By default, Livewire automatically injects its JavaScript and CSS into the + | and of pages containing Livewire components. By disabling + | this behavior, you need to use @livewireStyles and @livewireScripts. + | + */ + + 'inject_assets' => true, + + /* + |--------------------------------------------------------------------------- + | Navigate (SPA mode) + |--------------------------------------------------------------------------- + | + | By adding `wire:navigate` to links in your Livewire application, Livewire + | will prevent the default link handling and instead request those pages + | via AJAX, creating an SPA-like effect. Configure this behavior here. + | + */ + + 'navigate' => [ + 'show_progress_bar' => true, + 'progress_bar_color' => '#2299dd', + ], + + /* + |--------------------------------------------------------------------------- + | HTML Morph Markers + |--------------------------------------------------------------------------- + | + | Livewire intelligently "morphs" existing HTML into the newly rendered HTML + | after each update. To make this process more reliable, Livewire injects + | "markers" into the rendered Blade surrounding @if, @class & @foreach. + | + */ + + 'inject_morph_markers' => true, + + /* + |--------------------------------------------------------------------------- + | Smart Wire Keys + |--------------------------------------------------------------------------- + | + | Livewire uses loops and keys used within loops to generate smart keys that + | are applied to nested components that don't have them. This makes using + | nested components more reliable by ensuring that they all have keys. + | + */ + + 'smart_wire_keys' => true, + + /* + |--------------------------------------------------------------------------- + | Pagination Theme + |--------------------------------------------------------------------------- + | + | When enabling Livewire's pagination feature by using the `WithPagination` + | trait, Livewire will use Tailwind templates to render pagination views + | on the page. If you want Bootstrap CSS, you can specify: "bootstrap" + | + */ + + 'pagination_theme' => 'tailwind', + + /* + |--------------------------------------------------------------------------- + | Release Token + |--------------------------------------------------------------------------- + | + | This token is stored client-side and sent along with each request to check + | a users session to see if a new release has invalidated it. If there is + | a mismatch it will throw an error and prompt for a browser refresh. + | + */ + + 'release_token' => 'a', + + /* + |--------------------------------------------------------------------------- + | CSP Safe + |--------------------------------------------------------------------------- + | + | This config is used to determine if Livewire will use the CSP-safe version + | of Alpine in its bundle. This is useful for applications that are using + | strict Content Security Policy (CSP) to protect against XSS attacks. + | + */ + + 'csp_safe' => false, + + /* + |--------------------------------------------------------------------------- + | Payload Guards + |--------------------------------------------------------------------------- + | + | These settings protect against malicious or oversized payloads that could + | cause denial of service. The default values should feel reasonable for + | most web applications. Each can be set to null to disable the limit. + | + */ + + 'payload' => [ + 'max_size' => 1024 * 1024, // 1MB - maximum request payload size in bytes + 'max_nesting_depth' => 10, // Maximum depth of dot-notation property paths + 'max_calls' => 50, // Maximum method calls per request + 'max_components' => 20, // Maximum components per batch request + ], +]; diff --git a/config/octane.php b/config/octane.php new file mode 100644 index 0000000..8692ee1 --- /dev/null +++ b/config/octane.php @@ -0,0 +1,224 @@ + env('OCTANE_SERVER', 'frankenphp'), + + /* + |-------------------------------------------------------------------------- + | Force HTTPS + |-------------------------------------------------------------------------- + | + | When this configuration value is set to "true", Octane will inform the + | framework that all absolute links must be generated using the HTTPS + | protocol. Otherwise your links may be generated using plain HTTP. + | + */ + + 'https' => env('OCTANE_HTTPS', str_starts_with(env('APP_URL', ''), 'https://')), + + /* + |-------------------------------------------------------------------------- + | Octane Listeners + |-------------------------------------------------------------------------- + | + | All of the event listeners for Octane's events are defined below. These + | listeners are responsible for resetting your application's state for + | the next request. You may even add your own listeners to the list. + | + */ + + 'listeners' => [ + WorkerStarting::class => [ + EnsureUploadedFilesAreValid::class, + EnsureUploadedFilesCanBeMoved::class, + ], + + RequestReceived::class => [ + ...Octane::prepareApplicationForNextOperation(), + ...Octane::prepareApplicationForNextRequest(), + // + ], + + RequestHandled::class => [ + // + ], + + RequestTerminated::class => [ + // FlushUploadedFiles::class, + ], + + TaskReceived::class => [ + ...Octane::prepareApplicationForNextOperation(), + // + ], + + TaskTerminated::class => [ + // + ], + + TickReceived::class => [ + ...Octane::prepareApplicationForNextOperation(), + // + ], + + TickTerminated::class => [ + // + ], + + OperationTerminated::class => [ + FlushOnce::class, + FlushTemporaryContainerInstances::class, + // DisconnectFromDatabases::class, + // CollectGarbage::class, + ], + + WorkerErrorOccurred::class => [ + ReportException::class, + StopWorkerIfNecessary::class, + ], + + WorkerStopping::class => [ + CloseMonologHandlers::class, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Warm / Flush Bindings + |-------------------------------------------------------------------------- + | + | The bindings listed below will either be pre-warmed when a worker boots + | or they will be flushed before every new request. Flushing a binding + | will force the container to resolve that binding again when asked. + | + */ + + 'warm' => [ + ...Octane::defaultServicesToWarm(), + ], + + 'flush' => [ + // + ], + + /* + |-------------------------------------------------------------------------- + | Octane Swoole Tables + |-------------------------------------------------------------------------- + | + | While using Swoole, you may define additional tables as required by the + | application. These tables can be used to store data that needs to be + | quickly accessed by other workers on the particular Swoole server. + | + */ + + 'tables' => [ + 'example:1000' => [ + 'name' => 'string:1000', + 'votes' => 'int', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Octane Swoole Cache Table + |-------------------------------------------------------------------------- + | + | While using Swoole, you may leverage the Octane cache, which is powered + | by a Swoole table. You may set the maximum number of rows as well as + | the number of bytes per row using the configuration options below. + | + */ + + 'cache' => [ + 'rows' => 1000, + 'bytes' => 10000, + ], + + /* + |-------------------------------------------------------------------------- + | File Watching + |-------------------------------------------------------------------------- + | + | The following list of files and directories will be watched when using + | the --watch option offered by Octane. If any of the directories and + | files are changed, Octane will automatically reload your workers. + | + */ + + 'watch' => [ + 'app', + 'bootstrap', + 'config/**/*.php', + 'database/**/*.php', + 'public/**/*.php', + 'resources/**/*.php', + 'routes', + 'composer.lock', + '.env', + ], + + /* + |-------------------------------------------------------------------------- + | Garbage Collection Threshold + |-------------------------------------------------------------------------- + | + | When executing long-lived PHP scripts such as Octane, memory can build + | up before being cleared by PHP. You can force Octane to run garbage + | collection if your application consumes this amount of megabytes. + | + */ + + 'garbage' => 50, + + /* + |-------------------------------------------------------------------------- + | Maximum Execution Time + |-------------------------------------------------------------------------- + | + | The following setting configures the maximum execution time for requests + | being handled by Octane. You may set this value to 0 to indicate that + | there isn't a specific time limit on Octane request execution time. + | + */ + + 'max_execution_time' => env('OCTANE_MAX_EXECUTION_TIME', 300), + +]; diff --git a/database/factories/ShareFactory.php b/database/factories/ShareFactory.php new file mode 100644 index 0000000..84a7b11 --- /dev/null +++ b/database/factories/ShareFactory.php @@ -0,0 +1,60 @@ + + */ +class ShareFactory extends Factory +{ + protected $model = Share::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'token' => Str::random(16), + 'password' => null, + 'encryption_key' => null, + 'encryption_salt' => bin2hex(random_bytes(32)), + 'expires_at' => null, + 'max_downloads' => null, + 'download_count' => 0, + 'total_size' => 0, + ]; + } + + public function withPassword(string $password = 'secret'): static + { + return $this->state(fn (array $attributes) => [ + 'password' => bcrypt($password), + ]); + } + + public function expired(): static + { + return $this->state(fn (array $attributes) => [ + 'expires_at' => now()->subHour(), + ]); + } + + public function withMaxDownloads(int $max = 5): static + { + return $this->state(fn (array $attributes) => [ + 'max_downloads' => $max, + ]); + } + + public function expiresInHours(int $hours = 24): static + { + return $this->state(fn (array $attributes) => [ + 'expires_at' => now()->addHours($hours), + ]); + } +} diff --git a/database/factories/ShareFileFactory.php b/database/factories/ShareFileFactory.php new file mode 100644 index 0000000..770197b --- /dev/null +++ b/database/factories/ShareFileFactory.php @@ -0,0 +1,30 @@ + + */ +class ShareFileFactory extends Factory +{ + protected $model = ShareFile::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'share_id' => Share::factory(), + 'original_name' => fake()->word().'.txt', + 'relative_path' => null, + 'stored_path' => 'shares/'.fake()->uuid().'.enc', + 'file_size' => fake()->numberBetween(1024, 10485760), + 'mime_type' => 'text/plain', + ]; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 80da5ac..15e0717 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -45,6 +45,16 @@ class UserFactory extends Factory ]); } + /** + * Indicate that the user is an admin. + */ + public function admin(): static + { + return $this->state(fn (array $attributes) => [ + 'is_admin' => true, + ]); + } + /** * Indicate that the model has two-factor authentication configured. */ diff --git a/database/migrations/2026_02_12_165033_add_is_admin_to_users_table.php b/database/migrations/2026_02_12_165033_add_is_admin_to_users_table.php new file mode 100644 index 0000000..312e980 --- /dev/null +++ b/database/migrations/2026_02_12_165033_add_is_admin_to_users_table.php @@ -0,0 +1,28 @@ +boolean('is_admin')->default(false)->after('remember_token'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('is_admin'); + }); + } +}; diff --git a/database/migrations/2026_02_12_165033_create_shares_table.php b/database/migrations/2026_02_12_165033_create_shares_table.php new file mode 100644 index 0000000..e594a01 --- /dev/null +++ b/database/migrations/2026_02_12_165033_create_shares_table.php @@ -0,0 +1,35 @@ +id(); + $table->string('token', 16)->unique(); + $table->string('password')->nullable(); + $table->text('encryption_key')->nullable(); + $table->string('encryption_salt')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->unsignedInteger('max_downloads')->nullable(); + $table->unsignedInteger('download_count')->default(0); + $table->unsignedBigInteger('total_size')->default(0); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('shares'); + } +}; diff --git a/database/migrations/2026_02_12_165034_create_share_files_table.php b/database/migrations/2026_02_12_165034_create_share_files_table.php new file mode 100644 index 0000000..3fcbdda --- /dev/null +++ b/database/migrations/2026_02_12_165034_create_share_files_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('share_id')->constrained()->cascadeOnDelete(); + $table->string('original_name'); + $table->string('relative_path')->nullable(); + $table->string('stored_path'); + $table->unsignedBigInteger('file_size')->default(0); + $table->string('mime_type')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('share_files'); + } +}; diff --git a/database/migrations/2026_02_12_165035_create_settings_table.php b/database/migrations/2026_02_12_165035_create_settings_table.php new file mode 100644 index 0000000..ce3b578 --- /dev/null +++ b/database/migrations/2026_02_12_165035_create_settings_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('key')->unique(); + $table->text('value')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('settings'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d01a0ef..c077e84 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,6 +2,7 @@ namespace Database\Seeders; +use App\Models\Setting; use App\Models\User; // use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; @@ -19,5 +20,8 @@ class DatabaseSeeder extends Seeder 'name' => 'Test User', 'email' => 'test@example.com', ]); + + Setting::set('site_title', 'SealShare'); + Setting::set('site_description', 'Simple, secure file sharing'); } } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..909111d --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,31 @@ +services: + app: + build: + context: . + dockerfile: docker/dev.Dockerfile + ports: + - "8000:8000" + - "5173:5173" + volumes: + - .:/app + environment: + APP_KEY: ${APP_KEY:-} + APP_URL: http://localhost:8000 + APP_ENV: local + APP_DEBUG: "true" + SERVER_NAME: ":8000" + DB_CONNECTION: sqlite + LOG_CHANNEL: stack + LOG_LEVEL: debug + OCTANE_MAX_EXECUTION_TIME: "300" + PHP_UPLOAD_MAX_FILESIZE: "4G" + PHP_POST_MAX_SIZE: "4G" + PHP_MAX_EXECUTION_TIME: "300" + PHP_MAX_INPUT_TIME: "300" + PHP_MEMORY_LIMIT: "512M" + healthcheck: + test: ["CMD", "curl", "--silent", "--fail", "http://localhost:8000/up"] + interval: 30s + timeout: 5s + start_period: 30s + retries: 3 diff --git a/docker-compose.example.yml b/docker-compose.example.yml new file mode 100644 index 0000000..29fad00 --- /dev/null +++ b/docker-compose.example.yml @@ -0,0 +1,90 @@ +# ============================================ +# SealShare - Docker Compose Configuration +# ============================================ +# +# Quick start: +# 1. Copy this file: cp docker-compose.example.yml docker-compose.yml +# 2. Edit the settings below (APP_URL and SERVER_NAME are required) +# 3. Start: docker compose up -d +# 4. Open your browser to your configured domain +# +# Note: APP_KEY is auto-generated on first start if not set. +# Copy it from the logs into your docker-compose.yml to persist across restarts. +# +# ============================================ + +services: + # ------------------------------------------ + # SealShare Application (FrankenPHP/Octane) + # ------------------------------------------ + app: + image: ghcr.io/surtic86/sealshare:latest + restart: unless-stopped + ports: + - "80:80" # HTTP + - "443:443" # HTTPS (auto TLS via Let's Encrypt when SERVER_NAME is a real domain) + - "443:443/udp" # HTTP/3 (QUIC) + volumes: + - sealshare_storage:/app/storage/app # Uploaded & encrypted files + - sealshare_database:/app/database # SQLite database + - caddy_data:/data # TLS certificates + - caddy_config:/config # Caddy configuration + environment: + # --- REQUIRED --- + APP_URL: # Your full URL, e.g. https://share.example.com + SERVER_NAME: # Your domain for auto-TLS, e.g. share.example.com (use "localhost" for local testing) + # APP_KEY: # Auto-generated if not set. Copy from logs to persist across restarts. + + # --- Optional: Application --- + # APP_ENV: production + # APP_DEBUG: "false" + # LOG_CHANNEL: stderr + # LOG_LEVEL: warning + + # --- Optional: Database --- + # DB_CONNECTION: sqlite # Options: sqlite, mysql, pgsql + # DB_HOST: # Required for mysql/pgsql + # DB_PORT: # Required for mysql/pgsql + # DB_DATABASE: # Required for mysql/pgsql + # DB_USERNAME: # Required for mysql/pgsql + # DB_PASSWORD: # Required for mysql/pgsql + + # --- Optional: Octane --- + # OCTANE_HTTPS: "false" # Set to "true" when using HTTPS + # OCTANE_MAX_EXECUTION_TIME: 300 # Max request execution time (seconds) + + # --- Optional: PHP upload limits --- + # PHP_UPLOAD_MAX_FILESIZE: "4G" # Max single file size + # PHP_POST_MAX_SIZE: "4G" # Max total request size + # PHP_MAX_EXECUTION_TIME: "300" # Upload timeout in seconds + # PHP_MAX_INPUT_TIME: "300" # Input processing timeout + # PHP_MEMORY_LIMIT: "512M" # PHP memory limit + healthcheck: + test: ["CMD", "curl", "--silent", "--fail", "http://localhost/up"] + interval: 30s + timeout: 5s + start_period: 10s + retries: 3 + + # ------------------------------------------ + # Scheduler - Runs cleanup for expired shares + # ------------------------------------------ + scheduler: + image: ghcr.io/surtic86/sealshare:latest + restart: unless-stopped + entrypoint: ["php", "artisan", "schedule:work"] + volumes: + - sealshare_storage:/app/storage/app + - sealshare_database:/app/database + environment: + # APP_KEY: # Same key as the app service above (auto-generated if not set) + APP_URL: # Same URL as the app service above + depends_on: + app: + condition: service_healthy + +volumes: + sealshare_storage: # Persistent: uploaded & encrypted files + sealshare_database: # Persistent: SQLite database + caddy_data: # Persistent: TLS certificates + caddy_config: # Persistent: Caddy configuration diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..56d8e66 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,79 @@ +services: + app: + image: ghcr.io/surtic86/sealshare:latest + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - sealshare_storage:/app/storage/app + - sealshare_database:/app/database + - caddy_data:/data + - caddy_config:/config + environment: + APP_KEY: ${APP_KEY:?Set APP_KEY in .env or environment} + APP_URL: ${APP_URL:-http://localhost} + APP_ENV: ${APP_ENV:-production} + APP_DEBUG: ${APP_DEBUG:-false} + SERVER_NAME: ${SERVER_NAME:-localhost} + DB_CONNECTION: ${DB_CONNECTION:-sqlite} + DB_HOST: ${DB_HOST:-} + DB_PORT: ${DB_PORT:-} + DB_DATABASE: ${DB_DATABASE:-} + DB_USERNAME: ${DB_USERNAME:-} + DB_PASSWORD: ${DB_PASSWORD:-} + LOG_CHANNEL: ${LOG_CHANNEL:-stderr} + LOG_LEVEL: ${LOG_LEVEL:-warning} + SESSION_DRIVER: ${SESSION_DRIVER:-database} + QUEUE_CONNECTION: ${QUEUE_CONNECTION:-database} + CACHE_STORE: ${CACHE_STORE:-database} + OCTANE_HTTPS: ${OCTANE_HTTPS:-false} + OCTANE_MAX_EXECUTION_TIME: ${OCTANE_MAX_EXECUTION_TIME:-300} + PHP_UPLOAD_MAX_FILESIZE: ${PHP_UPLOAD_MAX_FILESIZE:-4G} + PHP_POST_MAX_SIZE: ${PHP_POST_MAX_SIZE:-4G} + PHP_MAX_EXECUTION_TIME: ${PHP_MAX_EXECUTION_TIME:-300} + PHP_MAX_INPUT_TIME: ${PHP_MAX_INPUT_TIME:-300} + PHP_MEMORY_LIMIT: ${PHP_MEMORY_LIMIT:-512M} + healthcheck: + test: ["CMD", "curl", "--silent", "--fail", "http://localhost/up"] + interval: 30s + timeout: 5s + start_period: 10s + retries: 3 + + scheduler: + image: ghcr.io/surtic86/sealshare:latest + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + entrypoint: ["php", "artisan", "schedule:work"] + volumes: + - sealshare_storage:/app/storage/app + - sealshare_database:/app/database + environment: + APP_KEY: ${APP_KEY:?Set APP_KEY in .env or environment} + APP_URL: ${APP_URL:-http://localhost} + APP_ENV: ${APP_ENV:-production} + APP_DEBUG: ${APP_DEBUG:-false} + DB_CONNECTION: ${DB_CONNECTION:-sqlite} + DB_HOST: ${DB_HOST:-} + DB_PORT: ${DB_PORT:-} + DB_DATABASE: ${DB_DATABASE:-} + DB_USERNAME: ${DB_USERNAME:-} + DB_PASSWORD: ${DB_PASSWORD:-} + LOG_CHANNEL: ${LOG_CHANNEL:-stderr} + LOG_LEVEL: ${LOG_LEVEL:-warning} + depends_on: + app: + condition: service_healthy + +volumes: + sealshare_storage: + sealshare_database: + caddy_data: + caddy_config: diff --git a/docker/Caddyfile b/docker/Caddyfile new file mode 100644 index 0000000..2ea679d --- /dev/null +++ b/docker/Caddyfile @@ -0,0 +1,14 @@ +{ + frankenphp + order php_server before file_server + admin off +} + +{$SERVER_NAME:localhost} { + root * /app/public + encode zstd gzip + request_body { + max_size 4gb + } + php_server +} diff --git a/docker/dev-entrypoint.sh b/docker/dev-entrypoint.sh new file mode 100755 index 0000000..aeefbfa --- /dev/null +++ b/docker/dev-entrypoint.sh @@ -0,0 +1,29 @@ +#!/bin/sh +set -e + +cd /app + +# Generate PHP ini from environment variables (with defaults) +echo "[dev] Configuring PHP settings..." +cat > /usr/local/etc/php/conf.d/99-uploads.ini <&1 + +echo "[dev] Building frontend assets..." +npm run build 2>&1 + +echo "[dev] Running database migrations..." +php artisan migrate --force + +echo "[dev] Creating storage link..." +php artisan storage:link --force + +echo "[dev] Starting Octane (FrankenPHP) with --watch..." +exec php artisan octane:frankenphp --host=0.0.0.0 --port=8000 --watch --workers=1 --max-requests=1 diff --git a/docker/dev.Dockerfile b/docker/dev.Dockerfile new file mode 100644 index 0000000..f821183 --- /dev/null +++ b/docker/dev.Dockerfile @@ -0,0 +1,16 @@ +FROM dunglas/frankenphp:php8.5-alpine + +# Install required PHP extensions +RUN install-php-extensions \ + intl \ + pcntl + +# Install Node.js for Vite / frontend asset building +RUN apk add --no-cache nodejs npm + +WORKDIR /app + +COPY docker/dev-entrypoint.sh /usr/local/bin/dev-entrypoint.sh +RUN chmod +x /usr/local/bin/dev-entrypoint.sh + +ENTRYPOINT ["dev-entrypoint.sh"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..0744202 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,37 @@ +#!/bin/sh +set -e + +cd /app + +# Auto-generate APP_KEY if not provided +if [ -z "$APP_KEY" ]; then + echo "[entrypoint] No APP_KEY set, generating one..." + APP_KEY=$(php artisan key:generate --show) + export APP_KEY + echo "[entrypoint] Generated APP_KEY: $APP_KEY" + echo "[entrypoint] WARNING: Set this APP_KEY in your docker-compose.yml to persist across restarts!" +fi + +# Generate PHP ini from environment variables (with defaults) +echo "[entrypoint] Configuring PHP settings..." +cat > /usr/local/etc/php/conf.d/99-uploads.ini <=8" } }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -1316,6 +1361,15 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/daisyui": { + "version": "5.5.18", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.18.tgz", + "integrity": "sha512-VVzjpOitMGB6DWIBeRSapbjdOevFqyzpk9u5Um6a4tyId3JFrU5pbtF0vgjXDth76mJZbueN/j9Ok03SPrh/og==", + "license": "MIT", + "funding": { + "url": "https://github.com/saadeghi/daisyui?sponsor=1" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2096,6 +2150,20 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", diff --git a/package.json b/package.json index 688bea8..2e356bf 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,11 @@ }, "dependencies": { "@tailwindcss/vite": "^4.1.11", + "alpinejs": "^3.15.8", "autoprefixer": "^10.4.20", "axios": "^1.7.4", "concurrently": "^9.0.1", + "daisyui": "^5.5.18", "laravel-vite-plugin": "^2.0", "tailwindcss": "^4.0.7", "vite": "^7.0.4" @@ -19,5 +21,8 @@ "@rollup/rollup-linux-x64-gnu": "4.9.5", "@tailwindcss/oxide-linux-x64-gnu": "^4.0.1", "lightningcss-linux-x64-gnu": "^1.29.1" + }, + "devDependencies": { + "chokidar": "^5.0.0" } } diff --git a/public/.user.ini b/public/.user.ini new file mode 100644 index 0000000..9034b3e --- /dev/null +++ b/public/.user.ini @@ -0,0 +1,5 @@ +upload_max_filesize = 4G +post_max_size = 4G +max_execution_time = 300 +max_input_time = 300 +memory_limit = 512M diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index c2efef6..77176d5 100644 Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ diff --git a/public/favicon.ico b/public/favicon.ico index 236fadb..4a91d8e 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/favicon.svg b/public/favicon.svg index e4e710e..2c9a1af 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1,3 +1,7 @@ - - + + + + + + diff --git a/resources/css/app.css b/resources/css/app.css index ad6eeed..0ac24fc 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,66 +1,10 @@ @import 'tailwindcss'; -@import '../../vendor/livewire/flux/dist/flux.css'; @source '../views'; +@source '../../vendor/robsontenorio/mary/src/View/Components/**/*.php'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; -@source '../../vendor/livewire/flux-pro/stubs/**/*.blade.php'; -@source '../../vendor/livewire/flux/stubs/**/*.blade.php'; +@source inline("swap swap-rotate swap-on swap-off theme-controller"); -@custom-variant dark (&:where(.dark, .dark *)); - -@theme { - --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - - --color-zinc-50: #fafafa; - --color-zinc-100: #f5f5f5; - --color-zinc-200: #e5e5e5; - --color-zinc-300: #d4d4d4; - --color-zinc-400: #a3a3a3; - --color-zinc-500: #737373; - --color-zinc-600: #525252; - --color-zinc-700: #404040; - --color-zinc-800: #262626; - --color-zinc-900: #171717; - --color-zinc-950: #0a0a0a; - - --color-accent: var(--color-neutral-800); - --color-accent-content: var(--color-neutral-800); - --color-accent-foreground: var(--color-white); +@plugin "daisyui" { + themes: light --default, dark --prefersdark; } - -@layer theme { - .dark { - --color-accent: var(--color-white); - --color-accent-content: var(--color-white); - --color-accent-foreground: var(--color-neutral-800); - } -} - -@layer base { - - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentColor); - } -} - -[data-flux-field]:not(ui-radio, ui-checkbox) { - @apply grid gap-2; -} - -[data-flux-label] { - @apply !mb-0 !leading-tight; -} - -input:focus[data-flux-control], -textarea:focus[data-flux-control], -select:focus[data-flux-control] { - @apply outline-hidden ring-2 ring-accent ring-offset-2 ring-offset-accent-foreground; -} - -/* \[:where(&)\]:size-4 { - @apply size-4; -} */ diff --git a/resources/js/app.js b/resources/js/app.js index e69de29..19db2a3 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -0,0 +1,2 @@ +// Alpine.js is bundled and started automatically by Livewire 4. +// Do not import it here to avoid "multiple instances of Alpine" errors. diff --git a/resources/views/components/app-logo-icon.blade.php b/resources/views/components/app-logo-icon.blade.php index 0adc3a2..f3fbe92 100644 --- a/resources/views/components/app-logo-icon.blade.php +++ b/resources/views/components/app-logo-icon.blade.php @@ -1,8 +1,7 @@ - - + + {{-- Document outline with folded corner --}} + + + {{-- Upload arrow --}} + diff --git a/resources/views/components/app-logo.blade.php b/resources/views/components/app-logo.blade.php index 26e8f68..872af8e 100644 --- a/resources/views/components/app-logo.blade.php +++ b/resources/views/components/app-logo.blade.php @@ -1,17 +1,4 @@ -@props([ - 'sidebar' => false, -]) - -@if($sidebar) - - - - - -@else - - - - - -@endif +merge(['class' => 'flex items-center gap-2 font-semibold']) }} wire:navigate> + + {{ \App\Models\Setting::get('site_title') ?: config('app.name', 'SealShare') }} + diff --git a/resources/views/components/auth-header.blade.php b/resources/views/components/auth-header.blade.php index e596a3f..f8f0cb7 100644 --- a/resources/views/components/auth-header.blade.php +++ b/resources/views/components/auth-header.blade.php @@ -4,6 +4,6 @@ ])
- {{ $title }} - {{ $description }} +

{{ $title }}

+

{{ $description }}

diff --git a/resources/views/components/desktop-user-menu.blade.php b/resources/views/components/desktop-user-menu.blade.php index 958ed52..7622200 100644 --- a/resources/views/components/desktop-user-menu.blade.php +++ b/resources/views/components/desktop-user-menu.blade.php @@ -1,39 +1 @@ - - - - -
- -
- {{ auth()->user()->name }} - {{ auth()->user()->email }} -
-
- - - - {{ __('Settings') }} - -
- @csrf - - {{ __('Log Out') }} - -
-
-
-
+{{-- Desktop user menu - integrated into sidebar layout --}} diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index 8f08c05..fb52e77 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -1,18 +1,18 @@
-
- +
+
-
- +
+
-
- +
+
-
- +
+
diff --git a/resources/views/flux/icon/book-open-text.blade.php b/resources/views/flux/icon/book-open-text.blade.php deleted file mode 100644 index bff20a3..0000000 --- a/resources/views/flux/icon/book-open-text.blade.php +++ /dev/null @@ -1,47 +0,0 @@ -{{-- Credit: Lucide (https://lucide.dev) --}} - -@props([ - 'variant' => 'outline', -]) - -@php - if ($variant === 'solid') { - throw new \Exception('The "solid" variant is not supported in Lucide.'); - } - - $classes = Flux::classes('shrink-0')->add( - match ($variant) { - 'outline' => '[:where(&)]:size-6', - 'solid' => '[:where(&)]:size-6', - 'mini' => '[:where(&)]:size-5', - 'micro' => '[:where(&)]:size-4', - }, - ); - - $strokeWidth = match ($variant) { - 'outline' => 2, - 'mini' => 2.25, - 'micro' => 2.5, - }; -@endphp - -class($classes) }} - data-flux-icon - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="{{ $strokeWidth }}" - stroke-linecap="round" - stroke-linejoin="round" - aria-hidden="true" - data-slot="icon" -> - - - - - - - diff --git a/resources/views/flux/icon/chevrons-up-down.blade.php b/resources/views/flux/icon/chevrons-up-down.blade.php deleted file mode 100644 index bf1ba2b..0000000 --- a/resources/views/flux/icon/chevrons-up-down.blade.php +++ /dev/null @@ -1,43 +0,0 @@ -{{-- Credit: Lucide (https://lucide.dev) --}} - -@props([ - 'variant' => 'outline', -]) - -@php - if ($variant === 'solid') { - throw new \Exception('The "solid" variant is not supported in Lucide.'); - } - - $classes = Flux::classes('shrink-0')->add( - match ($variant) { - 'outline' => '[:where(&)]:size-6', - 'solid' => '[:where(&)]:size-6', - 'mini' => '[:where(&)]:size-5', - 'micro' => '[:where(&)]:size-4', - }, - ); - - $strokeWidth = match ($variant) { - 'outline' => 2, - 'mini' => 2.25, - 'micro' => 2.5, - }; -@endphp - -class($classes) }} - data-flux-icon - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="{{ $strokeWidth }}" - stroke-linecap="round" - stroke-linejoin="round" - aria-hidden="true" - data-slot="icon" -> - - - diff --git a/resources/views/flux/icon/folder-git-2.blade.php b/resources/views/flux/icon/folder-git-2.blade.php deleted file mode 100644 index 292171b..0000000 --- a/resources/views/flux/icon/folder-git-2.blade.php +++ /dev/null @@ -1,45 +0,0 @@ -{{-- Credit: Lucide (https://lucide.dev) --}} - -@props([ - 'variant' => 'outline', -]) - -@php - if ($variant === 'solid') { - throw new \Exception('The "solid" variant is not supported in Lucide.'); - } - - $classes = Flux::classes('shrink-0')->add( - match ($variant) { - 'outline' => '[:where(&)]:size-6', - 'solid' => '[:where(&)]:size-6', - 'mini' => '[:where(&)]:size-5', - 'micro' => '[:where(&)]:size-4', - }, - ); - - $strokeWidth = match ($variant) { - 'outline' => 2, - 'mini' => 2.25, - 'micro' => 2.5, - }; -@endphp - -class($classes) }} - data-flux-icon - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="{{ $strokeWidth }}" - stroke-linecap="round" - stroke-linejoin="round" - aria-hidden="true" - data-slot="icon" -> - - - - - diff --git a/resources/views/flux/icon/layout-grid.blade.php b/resources/views/flux/icon/layout-grid.blade.php deleted file mode 100644 index 88c5698..0000000 --- a/resources/views/flux/icon/layout-grid.blade.php +++ /dev/null @@ -1,45 +0,0 @@ -{{-- Credit: Lucide (https://lucide.dev) --}} - -@props([ - 'variant' => 'outline', -]) - -@php - if ($variant === 'solid') { - throw new \Exception('The "solid" variant is not supported in Lucide.'); - } - - $classes = Flux::classes('shrink-0')->add( - match ($variant) { - 'outline' => '[:where(&)]:size-6', - 'solid' => '[:where(&)]:size-6', - 'mini' => '[:where(&)]:size-5', - 'micro' => '[:where(&)]:size-4', - }, - ); - - $strokeWidth = match ($variant) { - 'outline' => 2, - 'mini' => 2.25, - 'micro' => 2.5, - }; -@endphp - -class($classes) }} - data-flux-icon - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="{{ $strokeWidth }}" - stroke-linecap="round" - stroke-linejoin="round" - aria-hidden="true" - data-slot="icon" -> - - - - - diff --git a/resources/views/flux/navlist/group.blade.php b/resources/views/flux/navlist/group.blade.php deleted file mode 100644 index 5e691a2..0000000 --- a/resources/views/flux/navlist/group.blade.php +++ /dev/null @@ -1,51 +0,0 @@ -@props([ - 'expandable' => false, - 'expanded' => true, - 'heading' => null, -]) - - - -class('group/disclosure') }} - @if ($expanded === true) open @endif - data-flux-navlist-group -> - - - - - - - -
class('block space-y-[2px]') }}> -
-
{{ $heading }}
-
- -
- {{ $slot }} -
-
- - - -
class('block space-y-[2px]') }}> - {{ $slot }} -
- - diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 037dd1b..f2e85e6 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -1,5 +1,3 @@ - - {{ $slot }} - + {{ $slot }} diff --git a/resources/views/layouts/app/header.blade.php b/resources/views/layouts/app/header.blade.php index e1f84d9..919020a 100644 --- a/resources/views/layouts/app/header.blade.php +++ b/resources/views/layouts/app/header.blade.php @@ -1,78 +1,4 @@ - - - - @include('partials.head') - - - - - - - - - - {{ __('Dashboard') }} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {{ __('Dashboard') }} - - - - - - - - - {{ __('Repository') }} - - - {{ __('Documentation') }} - - - - - {{ $slot }} - - @fluxScripts - - +{{-- Header layout not used - redirects to sidebar layout --}} + + {{ $slot }} + diff --git a/resources/views/layouts/app/sidebar.blade.php b/resources/views/layouts/app/sidebar.blade.php index f24af84..c11123d 100644 --- a/resources/views/layouts/app/sidebar.blade.php +++ b/resources/views/layouts/app/sidebar.blade.php @@ -1,96 +1,42 @@ - + + @include('partials.head') - - - - - - + - - - - {{ __('Dashboard') }} - - - + {{-- MAIN CONTENT --}} +
+ {{ $slot }} +
- + {{-- FOOTER NAV --}} + - - - {{ __('Repository') }} - - - - {{ __('Documentation') }} - - - -
- - - - - - - - - - - - - -
-
- - -
- {{ auth()->user()->name }} - {{ auth()->user()->email }} -
-
-
-
- - - - - - {{ __('Settings') }} - - - - - -
- @csrf - - {{ __('Log Out') }} - -
-
-
-
- - {{ $slot }} - - @fluxScripts + {{-- Toast --}} + diff --git a/resources/views/layouts/auth/card.blade.php b/resources/views/layouts/auth/card.blade.php index db94716..f3582f0 100644 --- a/resources/views/layouts/auth/card.blade.php +++ b/resources/views/layouts/auth/card.blade.php @@ -1,26 +1,4 @@ - - - - @include('partials.head') - - - - @fluxScripts - - +{{-- Card auth layout - delegates to simple layout --}} + + {{ $slot }} + diff --git a/resources/views/layouts/auth/simple.blade.php b/resources/views/layouts/auth/simple.blade.php index 6e0d909..9ad4a14 100644 --- a/resources/views/layouts/auth/simple.blade.php +++ b/resources/views/layouts/auth/simple.blade.php @@ -1,22 +1,27 @@ - + + @include('partials.head') + @livewireStyles - -
+ + - @fluxScripts + @livewireScripts diff --git a/resources/views/layouts/auth/split.blade.php b/resources/views/layouts/auth/split.blade.php index 4e9788b..40e7ac0 100644 --- a/resources/views/layouts/auth/split.blade.php +++ b/resources/views/layouts/auth/split.blade.php @@ -1,43 +1,4 @@ - - - - @include('partials.head') - - -
- - -
- @fluxScripts - - +{{-- Split auth layout - delegates to simple layout --}} + + {{ $slot }} + diff --git a/resources/views/livewire/admin/admin-dashboard.blade.php b/resources/views/livewire/admin/admin-dashboard.blade.php new file mode 100644 index 0000000..0052035 --- /dev/null +++ b/resources/views/livewire/admin/admin-dashboard.blade.php @@ -0,0 +1,70 @@ +
+

{{ __('Admin Dashboard') }}

+ + {{-- Stats --}} +
+
+
+

{{ __('Total Shares') }}

+

{{ $totalShares }}

+
+
+
+
+

{{ __('Active Shares') }}

+

{{ $activeShares }}

+
+
+
+
+

{{ __('Total Files') }}

+

{{ $totalFiles }}

+
+
+
+
+

{{ __('Disk Usage') }}

+

{{ Number::fileSize($usedSpace) }}

+ +

{{ Number::fileSize($usedSpace) }} / {{ Number::fileSize($maxQuota) }}

+
+
+
+ + {{-- Shares Table --}} + + + @scope('cell_total_size', $share) + {{ Number::fileSize($share->total_size) }} + @endscope + + @scope('cell_expires_at', $share) + @if ($share->expires_at) + + {{ $share->expires_at->diffForHumans() }} + + @else + {{ __('Never') }} + @endif + @endscope + + @scope('cell_created_at', $share) + {{ $share->created_at->diffForHumans() }} + @endscope + + @scope('actions', $share) +
+ + + + +
+ @endscope +
+
+
diff --git a/resources/views/livewire/admin/admin-settings.blade.php b/resources/views/livewire/admin/admin-settings.blade.php new file mode 100644 index 0000000..2627b6a --- /dev/null +++ b/resources/views/livewire/admin/admin-settings.blade.php @@ -0,0 +1,151 @@ +
+

{{ __('System Settings') }}

+ + @if (session('message')) +
+ + {{ session('message') }} +
+ @endif + +
+ +
+ + + + +
+ + + @if ($currentLogo) +
+ {{ __('Site Logo') }} + +
+ @endif + + + + @if ($siteLogo && is_object($siteLogo)) +
+ @if (str_contains($siteLogo->getMimeType(), 'svg')) +

{{ __('SVG selected: :name', ['name' => $siteLogo->getClientOriginalName()]) }}

+ @else +

{{ __('Preview:') }}

+ {{ __('Logo preview') }} + @endif +
+ @endif + + @error('siteLogo') +

{{ $message }}

+ @enderror + +

{{ __('Max 2MB. Recommended: PNG or SVG.') }}

+
+
+
+ + +
+
+ + + @if ($hasSystemPassword) +
+ +
+ @endif +
+
+
+ + +
+ + + + + + + + + +
+
+ + +
+ +
+
+ + + +
diff --git a/resources/views/livewire/file-uploader.blade.php b/resources/views/livewire/file-uploader.blade.php new file mode 100644 index 0000000..94d73b8 --- /dev/null +++ b/resources/views/livewire/file-uploader.blade.php @@ -0,0 +1,193 @@ +
+
+ @if ($siteLogo) + {{ $siteTitle ?: config('app.name', 'SealShare') }} + @else + + @endif + +

{{ $siteTitle ?: config('app.name', 'SealShare') }}

+ +

{{ $siteDescription ?: __('Share your files safely and securely') }}

+
+ + @if ($isStorageFull) +
+ + {{ __('Storage is full. Uploads are temporarily disabled.') }} +
+ @else +
+ {{-- Drop Zone --}} +
+ +

{{ __('Drag & drop files or folders here') }}

+

{{ __('or click to browse') }}

+ + +
+ + {{-- Upload Progress --}} +
+ + +
+ + {{-- Errors --}} + @error('files') +
{{ $message }}
+ @enderror + + {{-- File List --}} + @if (count($files)) +
+

{{ __('Selected Files') }} ({{ count($files) }})

+
+ @foreach ($files as $index => $file) +
+
+ + + {{ $relativePaths[$index] ?? $file->getClientOriginalName() }} + + + ({{ Number::fileSize($file->getSize()) }}) + +
+ +
+ @endforeach +
+
+ @endif + + {{-- Options --}} + +
+ + + @if ($usePassword) + + @endif + + + + +
+
+ + {{-- Submit --}} + + + @endif +
diff --git a/resources/views/livewire/setup-wizard.blade.php b/resources/views/livewire/setup-wizard.blade.php new file mode 100644 index 0000000..07028eb --- /dev/null +++ b/resources/views/livewire/setup-wizard.blade.php @@ -0,0 +1,40 @@ +
+ + +
+ + + + + + + + + + +
diff --git a/resources/views/livewire/share-created.blade.php b/resources/views/livewire/share-created.blade.php new file mode 100644 index 0000000..a251eb2 --- /dev/null +++ b/resources/views/livewire/share-created.blade.php @@ -0,0 +1,65 @@ +
+ +
+ {{-- Share URL --}} +
+ +
+ + +
+
+ + {{-- Details --}} +
+
+ {{ __('Files') }} +

{{ $share->files->count() }}

+
+
+ {{ __('Total Size') }} +

{{ Number::fileSize($share->total_size) }}

+
+
+ {{ __('Expires') }} +

{{ $share->expires_at ? $share->expires_at->diffForHumans() : __('Never') }}

+
+
+ {{ __('Max Downloads') }} +

{{ $share->max_downloads ?? __('Unlimited') }}

+
+
+ + @if ($share->isPasswordProtected()) +
+ + {{ __('This share is password protected') }} +
+ @endif +
+ + + + +
+
diff --git a/resources/views/livewire/share-download.blade.php b/resources/views/livewire/share-download.blade.php new file mode 100644 index 0000000..337fdc3 --- /dev/null +++ b/resources/views/livewire/share-download.blade.php @@ -0,0 +1,69 @@ +
+
+ @if ($siteLogo) + {{ $siteTitle ?: config('app.name', 'SealShare') }} + @else + + @endif + +

{{ $siteTitle ?: config('app.name', 'SealShare') }}

+ +

{{ $siteDescription ?: __('Share your files safely and securely') }}

+
+ + @if (! $authenticated) + {{-- Password form --}} + +
+ + + + + + +
+ @else + {{-- File list --}} + +
+ @foreach ($share->files as $file) +
+
+ + {{ $file->relative_path ?: $file->original_name }} + ({{ Number::fileSize($file->file_size) }}) +
+ + + +
+ @endforeach +
+ + @if ($share->expires_at) +

+ {{ __('Expires') }}: {{ $share->expires_at->diffForHumans() }} +

+ @endif + + + @if ($share->files->count() > 1) + + + {{ __('Download All as ZIP') }} + + @else + + + {{ __('Download') }} + + @endif + +
+ @endif +
diff --git a/resources/views/livewire/system-password-prompt.blade.php b/resources/views/livewire/system-password-prompt.blade.php new file mode 100644 index 0000000..ef8e738 --- /dev/null +++ b/resources/views/livewire/system-password-prompt.blade.php @@ -0,0 +1,14 @@ +
+ + +
+ + + + +
diff --git a/resources/views/pages/auth/confirm-password.blade.php b/resources/views/pages/auth/confirm-password.blade.php index 09b2fbc..d27d193 100644 --- a/resources/views/pages/auth/confirm-password.blade.php +++ b/resources/views/pages/auth/confirm-password.blade.php @@ -10,19 +10,15 @@
@csrf - - - {{ __('Confirm') }} - +
diff --git a/resources/views/pages/auth/forgot-password.blade.php b/resources/views/pages/auth/forgot-password.blade.php index 4af4847..f6fec84 100644 --- a/resources/views/pages/auth/forgot-password.blade.php +++ b/resources/views/pages/auth/forgot-password.blade.php @@ -9,23 +9,22 @@ @csrf - - - {{ __('Email password reset link') }} - + -
+
{{ __('Or, return to') }} - {{ __('log in') }} + {{ __('log in') }}
diff --git a/resources/views/pages/auth/login.blade.php b/resources/views/pages/auth/login.blade.php index 0fee9de..f2b3808 100644 --- a/resources/views/pages/auth/login.blade.php +++ b/resources/views/pages/auth/login.blade.php @@ -9,50 +9,50 @@ @csrf -
- @if (Route::has('password.request')) - + {{ __('Forgot your password?') }} - + @endif
- +
- - {{ __('Log in') }} - +
@if (Route::has('register')) -
+
{{ __('Don\'t have an account?') }} - {{ __('Sign up') }} + {{ __('Sign up') }}
@endif
diff --git a/resources/views/pages/auth/register.blade.php b/resources/views/pages/auth/register.blade.php index 381ec0a..e559bed 100644 --- a/resources/views/pages/auth/register.blade.php +++ b/resources/views/pages/auth/register.blade.php @@ -8,60 +8,56 @@
@csrf - - - -
- - {{ __('Create account') }} - +
-
+
{{ __('Already have an account?') }} - {{ __('Log in') }} + {{ __('Log in') }}
diff --git a/resources/views/pages/auth/reset-password.blade.php b/resources/views/pages/auth/reset-password.blade.php index 1b6bd53..53d69a4 100644 --- a/resources/views/pages/auth/reset-password.blade.php +++ b/resources/views/pages/auth/reset-password.blade.php @@ -11,41 +11,36 @@ - - -
- - {{ __('Reset password') }} - +
diff --git a/resources/views/pages/auth/two-factor-challenge.blade.php b/resources/views/pages/auth/two-factor-challenge.blade.php index bfba986..c209893 100644 --- a/resources/views/pages/auth/two-factor-challenge.blade.php +++ b/resources/views/pages/auth/two-factor-challenge.blade.php @@ -9,16 +9,12 @@ recovery_code: '', toggleInput() { this.showRecoveryInput = !this.showRecoveryInput; - this.code = ''; this.recovery_code = ''; - - $dispatch('clear-2fa-auth-code'); - $nextTick(() => { this.showRecoveryInput ? this.$refs.recovery_code?.focus() - : $dispatch('focus-2fa-auth-code'); + : this.$refs.code?.focus(); }); }, }" @@ -42,21 +38,24 @@
-
- + + x-ref="code" + x-model="code" + x-bind:required="!showRecoveryInput" + autocomplete="one-time-code" + placeholder="000000" + class="text-center text-2xl tracking-widest" + maxlength="6" + />
- @error('recovery_code') - - {{ $message }} - +

{{ $message }}

@enderror
- - {{ __('Continue') }} - + label="{{ __('Continue') }}" + class="btn-primary w-full" + />
diff --git a/resources/views/pages/auth/verify-email.blade.php b/resources/views/pages/auth/verify-email.blade.php index 252d7bc..121f434 100644 --- a/resources/views/pages/auth/verify-email.blade.php +++ b/resources/views/pages/auth/verify-email.blade.php @@ -1,28 +1,24 @@
- +

{{ __('Please verify your email address by clicking on the link we just emailed to you.') }} - +

@if (session('status') == 'verification-link-sent') - +

{{ __('A new verification link has been sent to the email address you provided during registration.') }} - +

@endif
@csrf - - {{ __('Resend verification email') }} - +
@csrf - - {{ __('Log out') }} - +
diff --git a/resources/views/pages/settings/layout.blade.php b/resources/views/pages/settings/layout.blade.php index 17c7a0a..68d4e85 100644 --- a/resources/views/pages/settings/layout.blade.php +++ b/resources/views/pages/settings/layout.blade.php @@ -1,20 +1,20 @@
- - {{ __('Profile') }} - {{ __('Password') }} + + + @if (Laravel\Fortify\Features::canManageTwoFactorAuthentication()) - {{ __('Two-Factor Auth') }} + @endif - {{ __('Appearance') }} - + +
- +
- {{ $heading ?? '' }} - {{ $subheading ?? '' }} +

{{ $heading ?? '' }}

+

{{ $subheading ?? '' }}

{{ $slot }} diff --git a/resources/views/pages/settings/two-factor/⚡recovery-codes.blade.php b/resources/views/pages/settings/two-factor/⚡recovery-codes.blade.php index e41e97d..9e9f8a5 100644 --- a/resources/views/pages/settings/two-factor/⚡recovery-codes.blade.php +++ b/resources/views/pages/settings/two-factor/⚡recovery-codes.blade.php @@ -46,55 +46,46 @@ new class extends Component { }; ?>
- - {{ __('2FA Recovery Codes') }} + +

{{ __('2FA Recovery Codes') }}

- +

{{ __('Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.') }} - +

- + /> - - {{ __('Hide Recovery Codes') }} - + /> @if (filled($recoveryCodes)) - - {{ __('Regenerate Codes') }} - + /> @endif
@@ -107,12 +98,12 @@ new class extends Component { >
@error('recoveryCodes') - +
{{ $message }}
@enderror @if (filled($recoveryCodes))
@@ -126,9 +117,9 @@ new class extends Component {
@endforeach
- +

{{ __('Each recovery code can be used once to access your account and will be removed after use. If you need more, click Regenerate Codes above.') }} - +

@endif
diff --git a/resources/views/pages/settings/⚡appearance.blade.php b/resources/views/pages/settings/⚡appearance.blade.php index f8e251e..9964cb3 100644 --- a/resources/views/pages/settings/⚡appearance.blade.php +++ b/resources/views/pages/settings/⚡appearance.blade.php @@ -9,13 +9,7 @@ new class extends Component {
@include('partials.settings-heading') - {{ __('Appearance Settings') }} - - - {{ __('Light') }} - {{ __('Dark') }} - {{ __('System') }} - +
diff --git a/resources/views/pages/settings/⚡delete-user-form.blade.php b/resources/views/pages/settings/⚡delete-user-form.blade.php index 96cf9bc..b06471c 100644 --- a/resources/views/pages/settings/⚡delete-user-form.blade.php +++ b/resources/views/pages/settings/⚡delete-user-form.blade.php @@ -9,6 +9,7 @@ new class extends Component { use PasswordValidationRules; public string $password = ''; + public bool $showDeleteModal = false; /** * Delete the currently authenticated user. @@ -27,37 +28,29 @@ new class extends Component {
- {{ __('Delete account') }} - {{ __('Delete your account and all of its resources') }} +

{{ __('Delete account') }}

+

{{ __('Delete your account and all of its resources') }}

- - - {{ __('Delete account') }} - - + - -
-
- {{ __('Are you sure you want to delete your account?') }} + +

+ {{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }} +

- - {{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }} - -
+ + - - -
- - {{ __('Cancel') }} - - - - {{ __('Delete account') }} - -
+ + + + -
+
diff --git a/resources/views/pages/settings/⚡password.blade.php b/resources/views/pages/settings/⚡password.blade.php index a6bc155..0e4cf1a 100644 --- a/resources/views/pages/settings/⚡password.blade.php +++ b/resources/views/pages/settings/⚡password.blade.php @@ -42,37 +42,30 @@ new class extends Component {
@include('partials.settings-heading') - {{ __('Password Settings') }} -
- - -
- - {{ __('Save') }} - +
diff --git a/resources/views/pages/settings/⚡profile.blade.php b/resources/views/pages/settings/⚡profile.blade.php index b8edc6b..30d6f2e 100644 --- a/resources/views/pages/settings/⚡profile.blade.php +++ b/resources/views/pages/settings/⚡profile.blade.php @@ -79,29 +79,27 @@ new class extends Component {
@include('partials.settings-heading') - {{ __('Profile Settings') }} - - +
- + @if ($this->hasUnverifiedEmail)
- +

{{ __('Your email address is unverified.') }} - + {{ __('Click here to re-send the verification email.') }} - - + +

@if (session('status') === 'verification-link-sent') - +

{{ __('A new verification link has been sent to your email address.') }} - +

@endif
@endif @@ -109,9 +107,7 @@ new class extends Component {
- - {{ __('Save') }} - +
diff --git a/resources/views/pages/settings/⚡two-factor.blade.php b/resources/views/pages/settings/⚡two-factor.blade.php index 401549c..be85574 100644 --- a/resources/views/pages/settings/⚡two-factor.blade.php +++ b/resources/views/pages/settings/⚡two-factor.blade.php @@ -180,8 +180,6 @@ new class extends Component {
@include('partials.settings-heading') - {{ __('Two-Factor Authentication Settings') }} -
- {{ __('Enabled') }} + {{ __('Enabled') }}
- +

{{ __('With two-factor authentication enabled, you will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.') }} - +

- - {{ __('Disable 2FA') }} - + />
@else
- {{ __('Disabled') }} + {{ __('Disabled') }}
- +

{{ __('When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.') }} - +

- - {{ __('Enable 2FA') }} - + />
@endif
- -
-
-
-
-
- @for ($i = 1; $i <= 5; $i++) -
- @endfor -
+ +

{{ $this->modalConfig['description'] }}

-
- @for ($i = 1; $i <= 5; $i++) -
- @endfor -
- - -
+ @if ($showVerificationStep) +
+
+
-
- {{ $this->modalConfig['title'] }} - {{ $this->modalConfig['description'] }} + + + + +
+ @else + @error('setupData') +
{{ $message }}
+ @enderror + +
+
+ @empty($qrCodeSvg) +
+ +
+ @else +
+ {!! $qrCodeSvg !!} +
+ @endempty
- @if ($showVerificationStep) -
-
- -
- -
- - {{ __('Back') }} - - - - {{ __('Confirm') }} - -
-
- @else - @error('setupData') - - @enderror - -
-
- @empty($qrCodeSvg) -
- -
- @else -
-
- {!! $qrCodeSvg !!} -
-
- @endempty -
+
+
+
+ + {{ __('or, enter the code manually') }} +
-
- - {{ $this->modalConfig['buttonText'] }} - -
- -
-
-
- - {{ __('or, enter the code manually') }} - -
- -
-
- @empty($manualSetupKey) -
- -
- @else - - - - @endempty -
+ } + }" + > +
+ +
- @endif -
- +
+ + + + + @endif +
diff --git a/resources/views/partials/head.blade.php b/resources/views/partials/head.blade.php index dce8058..de19ac7 100644 --- a/resources/views/partials/head.blade.php +++ b/resources/views/partials/head.blade.php @@ -1,7 +1,7 @@ -{{ $title ?? config('app.name') }} +{{ $title ?? (\App\Models\Setting::get('site_title') ?: config('app.name')) }} @@ -11,4 +11,3 @@ @vite(['resources/css/app.css', 'resources/js/app.js']) -@fluxAppearance diff --git a/resources/views/partials/settings-heading.blade.php b/resources/views/partials/settings-heading.blade.php index 925ace9..5090a47 100644 --- a/resources/views/partials/settings-heading.blade.php +++ b/resources/views/partials/settings-heading.blade.php @@ -1,5 +1,5 @@
- {{ __('Settings') }} - {{ __('Manage your profile and account settings') }} - +

{{ __('Settings') }}

+

{{ __('Manage your profile and account settings') }}

+
diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php index a808a39..7bd3979 100644 --- a/resources/views/welcome.blade.php +++ b/resources/views/welcome.blade.php @@ -1,278 +1,17 @@ - - - - - - Laravel - - - - - - - - - - - - - -
- @if (Route::has('login')) - - @endif -
-
-
-
-

Let's get started

-

Laravel has an incredibly rich ecosystem.
We suggest starting with the following.

- - -
-
- {{-- Laravel Logo --}} - - - - - - - - - - - {{-- Light Mode 12 SVG --}} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {{-- Dark Mode 12 SVG --}} - -
-
-
-
- - @if (Route::has('login')) - - @endif - + + + + + + {{ \App\Models\Setting::get('site_title') ?: config('app.name', 'SealShare') }} + @include('partials.head') + + +
+

{{ \App\Models\Setting::get('site_title') ?: config('app.name', 'SealShare') }}

+

{{ __('Secure file sharing made simple.') }}

+ {{ __('Upload Files') }} +
+ diff --git a/routes/console.php b/routes/console.php index 3c9adf1..61f85d1 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,10 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::command('shares:cleanup')->hourly(); diff --git a/routes/web.php b/routes/web.php index f755f11..6bb2a5c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,11 +1,37 @@ route('upload'); })->name('home'); +Route::livewire('setup', SetupWizard::class)->name('setup'); + +Route::livewire('system-password', SystemPasswordPrompt::class)->name('system-password'); + +Route::middleware(['system.password'])->group(function () { + Route::livewire('upload', FileUploader::class)->name('upload'); + Route::livewire('share/{share:token}/created', ShareCreated::class)->name('share.created'); +}); + +Route::livewire('s/{share:token}', ShareDownload::class)->name('share.download'); +Route::get('s/{share:token}/download', [DownloadController::class, 'download'])->name('share.download.all'); +Route::get('s/{share:token}/download/{shareFile}', [DownloadController::class, 'downloadFile'])->name('share.download.file'); + +Route::middleware(['auth', 'admin'])->prefix('admin')->group(function () { + Route::livewire('dashboard', AdminDashboard::class)->name('admin.dashboard'); + Route::livewire('settings', AdminSettings::class)->name('admin.settings'); +}); + Route::view('dashboard', 'dashboard') ->middleware(['auth', 'verified']) ->name('dashboard'); diff --git a/tests/Feature/Admin/AdminDashboardTest.php b/tests/Feature/Admin/AdminDashboardTest.php new file mode 100644 index 0000000..095f0e0 --- /dev/null +++ b/tests/Feature/Admin/AdminDashboardTest.php @@ -0,0 +1,70 @@ +get(route('admin.dashboard')); + + $response->assertRedirect(route('login')); +}); + +test('non-admin user cannot access admin dashboard', function () { + $user = User::factory()->create(['is_admin' => false]); + + $response = $this->actingAs($user)->get(route('admin.dashboard')); + + $response->assertForbidden(); +}); + +test('admin can access dashboard', function () { + $admin = User::query()->where('is_admin', true)->first(); + + $response = $this->actingAs($admin)->get(route('admin.dashboard')); + + $response->assertOk(); +}); + +test('admin dashboard shows stats', function () { + $admin = User::query()->where('is_admin', true)->first(); + + $share = Share::factory()->create(['total_size' => 1024]); + ShareFile::factory()->create(['share_id' => $share->id]); + + $response = $this->actingAs($admin)->get(route('admin.dashboard')); + + $response->assertOk(); + $response->assertSee('Total Shares'); + $response->assertSee('Active Shares'); + $response->assertSee('Total Files'); + $response->assertSee('Disk Usage'); +}); + +test('admin can delete share', function () { + Storage::fake('shares'); + + $admin = User::query()->where('is_admin', true)->first(); + + $share = Share::factory()->create(); + $shareId = $share->id; + + Livewire::actingAs($admin) + ->test(\App\Livewire\Admin\AdminDashboard::class) + ->call('deleteShare', $shareId); + + expect(Share::query()->find($shareId))->toBeNull(); +}); + +test('admin dashboard shows shares table', function () { + $admin = User::query()->where('is_admin', true)->first(); + + $share = Share::factory()->create(['token' => 'testtoken12345678']); + + $response = $this->actingAs($admin)->get(route('admin.dashboard')); + + $response->assertOk(); + $response->assertSee('testtoken12345678'); +}); diff --git a/tests/Feature/Admin/AdminSettingsTest.php b/tests/Feature/Admin/AdminSettingsTest.php new file mode 100644 index 0000000..962762b --- /dev/null +++ b/tests/Feature/Admin/AdminSettingsTest.php @@ -0,0 +1,103 @@ +get(route('admin.settings')); + + $response->assertRedirect(route('login')); +}); + +test('non-admin user cannot access settings', function () { + $user = User::factory()->create(['is_admin' => false]); + + $response = $this->actingAs($user)->get(route('admin.settings')); + + $response->assertForbidden(); +}); + +test('admin can access settings page', function () { + $admin = User::query()->where('is_admin', true)->first(); + + $response = $this->actingAs($admin)->get(route('admin.settings')); + + $response->assertOk(); +}); + +test('admin can save settings', function () { + $admin = User::query()->where('is_admin', true)->first(); + + $phpMaxMb = \App\Livewire\Admin\AdminSettings::phpMaxUploadMb(); + + Livewire::actingAs($admin) + ->test(\App\Livewire\Admin\AdminSettings::class) + ->set('maxFileSize', min(200, $phpMaxMb)) + ->set('maxStorageQuota', 50) + ->set('maxFilesPerShare', 100) + ->set('maxSizePerShare', 5) + ->set('defaultExpiration', '7d') + ->call('saveSettings') + ->assertHasNoErrors(); + + expect(Setting::get('max_file_size'))->toBe((string) (min(200, $phpMaxMb) * 1024 * 1024)); + expect(Setting::get('max_storage_quota'))->toBe((string) (50 * 1024 * 1024 * 1024)); + expect(Setting::get('max_files_per_share'))->toBe('100'); + expect(Setting::get('max_size_per_share'))->toBe((string) (5 * 1024 * 1024 * 1024)); + expect(Setting::get('default_expiration'))->toBe('7d'); +}); + +test('admin can set system password', function () { + $admin = User::query()->where('is_admin', true)->first(); + $phpMaxMb = \App\Livewire\Admin\AdminSettings::phpMaxUploadMb(); + + Livewire::actingAs($admin) + ->test(\App\Livewire\Admin\AdminSettings::class) + ->set('maxFileSize', $phpMaxMb) + ->set('systemPassword', 'new-system-password') + ->call('saveSettings') + ->assertHasNoErrors(); + + $storedPassword = Setting::get('system_password'); + expect($storedPassword)->not->toBeNull(); + expect(\Illuminate\Support\Facades\Hash::check('new-system-password', $storedPassword))->toBeTrue(); +}); + +test('admin can clear system password', function () { + $admin = User::query()->where('is_admin', true)->first(); + + Setting::set('system_password', bcrypt('existing-password')); + + Livewire::actingAs($admin) + ->test(\App\Livewire\Admin\AdminSettings::class) + ->call('clearSystemPassword') + ->assertHasNoErrors(); + + expect(Setting::get('system_password'))->toBeNull(); +}); + +test('settings page loads existing values', function () { + $admin = User::query()->where('is_admin', true)->first(); + $phpMaxMb = \App\Livewire\Admin\AdminSettings::phpMaxUploadMb(); + $testSize = min(40, $phpMaxMb); + + Setting::set('max_file_size', $testSize * 1024 * 1024); + Setting::set('max_files_per_share', 75); + + Livewire::actingAs($admin) + ->test(\App\Livewire\Admin\AdminSettings::class) + ->assertSet('maxFileSize', $testSize) + ->assertSet('maxFilesPerShare', 75); +}); + +test('settings validation rejects invalid values', function () { + $admin = User::query()->where('is_admin', true)->first(); + + Livewire::actingAs($admin) + ->test(\App\Livewire\Admin\AdminSettings::class) + ->set('maxFileSize', 0) + ->set('maxStorageQuota', 0) + ->call('saveSettings') + ->assertHasErrors(['maxFileSize', 'maxStorageQuota']); +}); diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php index 50a8530..d2b15c8 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -1,21 +1,7 @@ get(route('register')); +test('registration is disabled', function () { + $response = $this->get('/register'); - $response->assertOk(); + $response->assertNotFound(); }); - -test('new users can register', function () { - $response = $this->post(route('register.store'), [ - 'name' => 'John Doe', - 'email' => 'test@example.com', - 'password' => 'password', - 'password_confirmation' => 'password', - ]); - - $response->assertSessionHasNoErrors() - ->assertRedirect(route('dashboard', absolute: false)); - - $this->assertAuthenticated(); -}); \ No newline at end of file diff --git a/tests/Feature/CleanupExpiredSharesTest.php b/tests/Feature/CleanupExpiredSharesTest.php new file mode 100644 index 0000000..35f218a --- /dev/null +++ b/tests/Feature/CleanupExpiredSharesTest.php @@ -0,0 +1,60 @@ +expired()->create(); + $active = Share::factory()->expiresInHours(24)->create(); + $noExpiry = Share::factory()->create(['expires_at' => null]); + + $this->artisan('shares:cleanup') + ->expectsOutputToContain('Cleaned up 1 expired share(s)') + ->assertExitCode(0); + + expect(Share::query()->find($expired->id))->toBeNull(); + expect(Share::query()->find($active->id))->not->toBeNull(); + expect(Share::query()->find($noExpiry->id))->not->toBeNull(); +}); + +test('cleanup removes shares that reached download limit', function () { + Storage::fake('shares'); + + $reachedLimit = Share::factory()->withMaxDownloads(5)->create(['download_count' => 5]); + $underLimit = Share::factory()->withMaxDownloads(5)->create(['download_count' => 3]); + + $this->artisan('shares:cleanup') + ->expectsOutputToContain('Cleaned up 1 expired share(s)') + ->assertExitCode(0); + + expect(Share::query()->find($reachedLimit->id))->toBeNull(); + expect(Share::query()->find($underLimit->id))->not->toBeNull(); +}); + +test('cleanup handles no expired shares', function () { + Storage::fake('shares'); + + Share::factory()->create(['expires_at' => null]); + + $this->artisan('shares:cleanup') + ->expectsOutputToContain('Cleaned up 0 expired share(s)') + ->assertExitCode(0); +}); + +test('cleanup removes both expired and download-limited shares', function () { + Storage::fake('shares'); + + $expired = Share::factory()->expired()->create(); + $limitReached = Share::factory()->withMaxDownloads(1)->create(['download_count' => 1]); + $active = Share::factory()->create(['expires_at' => null]); + + $this->artisan('shares:cleanup') + ->expectsOutputToContain('Cleaned up 2 expired share(s)') + ->assertExitCode(0); + + expect(Share::query()->find($expired->id))->toBeNull(); + expect(Share::query()->find($limitReached->id))->toBeNull(); + expect(Share::query()->find($active->id))->not->toBeNull(); +}); diff --git a/tests/Feature/DefaultBrandingTest.php b/tests/Feature/DefaultBrandingTest.php new file mode 100644 index 0000000..2f5962a --- /dev/null +++ b/tests/Feature/DefaultBrandingTest.php @@ -0,0 +1,15 @@ +seed(); + + expect(Setting::get('site_title'))->toBe('SealShare'); +}); + +test('database seeder sets default site description', function () { + $this->seed(); + + expect(Setting::get('site_description'))->toBe('Simple, secure file sharing'); +}); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index a576279..fee439a 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -1,7 +1,7 @@ get(route('home')); - $response->assertOk(); -}); \ No newline at end of file + $response->assertRedirect(route('upload')); +}); diff --git a/tests/Feature/FileUploadTest.php b/tests/Feature/FileUploadTest.php new file mode 100644 index 0000000..3e9eec8 --- /dev/null +++ b/tests/Feature/FileUploadTest.php @@ -0,0 +1,131 @@ +get(route('upload')); + + $response->assertOk(); +}); + +test('upload page requires system password when configured', function () { + Setting::set('system_password', bcrypt('system-secret')); + + $response = $this->get(route('upload')); + + $response->assertRedirect(route('system-password')); +}); + +test('upload page accessible after system password verified', function () { + Setting::set('system_password', bcrypt('system-secret')); + + $response = $this->withSession(['system_password_verified' => true]) + ->get(route('upload')); + + $response->assertOk(); +}); + +test('file upload creates share', function () { + Storage::fake('shares'); + + $file = UploadedFile::fake()->create('document.pdf', 1024); + + Livewire::test(\App\Livewire\FileUploader::class) + ->set('files', [$file]) + ->call('createShare') + ->assertRedirectContains('/share/'); + + expect(Share::query()->count())->toBe(1); + + $share = Share::query()->first(); + expect($share->files)->toHaveCount(1); + expect($share->files->first()->original_name)->toBe('document.pdf'); +}); + +test('file upload with password creates password-protected share', function () { + Storage::fake('shares'); + + $file = UploadedFile::fake()->create('secret.txt', 512); + + Livewire::test(\App\Livewire\FileUploader::class) + ->set('files', [$file]) + ->set('usePassword', true) + ->set('password', 'my-password') + ->call('createShare') + ->assertRedirectContains('/share/'); + + $share = Share::query()->first(); + expect($share->isPasswordProtected())->toBeTrue(); +}); + +test('file upload with expiration sets expires_at', function () { + Storage::fake('shares'); + + $file = UploadedFile::fake()->create('file.txt', 256); + + Livewire::test(\App\Livewire\FileUploader::class) + ->set('files', [$file]) + ->set('expiration', '24h') + ->call('createShare') + ->assertRedirectContains('/share/'); + + $share = Share::query()->first(); + expect($share->expires_at)->not->toBeNull(); +}); + +test('file upload with max downloads sets limit', function () { + Storage::fake('shares'); + + $file = UploadedFile::fake()->create('file.txt', 256); + + Livewire::test(\App\Livewire\FileUploader::class) + ->set('files', [$file]) + ->set('maxDownloads', 5) + ->call('createShare') + ->assertRedirectContains('/share/'); + + $share = Share::query()->first(); + expect($share->max_downloads)->toBe(5); +}); + +test('file upload requires at least one file', function () { + Livewire::test(\App\Livewire\FileUploader::class) + ->set('files', []) + ->call('createShare') + ->assertHasErrors(['files']); +}); + +test('file upload blocks when storage is full', function () { + Storage::fake('shares'); + Setting::set('max_storage_quota', 100); + Share::factory()->create(['total_size' => 100]); + + $file = UploadedFile::fake()->create('file.txt', 1); + + Livewire::test(\App\Livewire\FileUploader::class) + ->set('files', [$file]) + ->call('createShare') + ->assertHasErrors(['files']); +}); + +test('system password prompt verifies correct password', function () { + Setting::set('system_password', bcrypt('system-secret')); + + Livewire::test(\App\Livewire\SystemPasswordPrompt::class) + ->set('password', 'system-secret') + ->call('verify') + ->assertRedirect(route('upload')); +}); + +test('system password prompt rejects incorrect password', function () { + Setting::set('system_password', bcrypt('system-secret')); + + Livewire::test(\App\Livewire\SystemPasswordPrompt::class) + ->set('password', 'wrong') + ->call('verify') + ->assertHasErrors(['password']); +}); diff --git a/tests/Feature/SecurityAuditTest.php b/tests/Feature/SecurityAuditTest.php new file mode 100644 index 0000000..74306a4 --- /dev/null +++ b/tests/Feature/SecurityAuditTest.php @@ -0,0 +1,284 @@ +create('file.txt', 100); + + $share = $service->createShare([ + ['file' => $file, 'relativePath' => null], + ], [ + 'password' => 'test-password-secure', + ]); + + Livewire::test(ShareDownload::class, ['share' => $share]) + ->set('password', 'test-password-secure') + ->call('verifyPassword'); + + expect(session('share_key_'.$share->token))->not->toBeNull(); + expect(session('share_key_'.$share->token))->not->toBe('test-password-secure'); + expect(strlen(session('share_key_'.$share->token)))->toBe(64); +}); + +// --- Rate limiting on password verification --- + +test('rate limiting blocks after 5 failed password attempts', function () { + Storage::fake('shares'); + + $service = app(ShareService::class); + $file = UploadedFile::fake()->create('file.txt', 100); + + $share = $service->createShare([ + ['file' => $file, 'relativePath' => null], + ], [ + 'password' => 'correct-password', + ]); + + $component = Livewire::test(ShareDownload::class, ['share' => $share]); + + for ($i = 0; $i < 5; $i++) { + $component->set('password', 'wrong-password') + ->call('verifyPassword') + ->assertHasErrors(['password']); + } + + $component->set('password', 'correct-password') + ->call('verifyPassword') + ->assertHasErrors(['password']) + ->assertSet('authenticated', false); +}); + +test('rate limiter clears after successful password verification', function () { + Storage::fake('shares'); + + $service = app(ShareService::class); + $file = UploadedFile::fake()->create('file.txt', 100); + + $share = $service->createShare([ + ['file' => $file, 'relativePath' => null], + ], [ + 'password' => 'correct-password', + ]); + + $component = Livewire::test(ShareDownload::class, ['share' => $share]); + + $component->set('password', 'wrong-password') + ->call('verifyPassword') + ->assertHasErrors(['password']); + + $component->set('password', 'correct-password') + ->call('verifyPassword') + ->assertSet('authenticated', true); + + $rateLimitKey = 'share-password:'.$share->token.'|127.0.0.1'; + expect(RateLimiter::remaining($rateLimitKey, 5))->toBe(5); +}); + +// --- Share password minimum length --- + +test('share password must be at least 8 characters', function () { + Storage::fake('shares'); + + $file = UploadedFile::fake()->create('file.txt', 100); + + Livewire::test(FileUploader::class) + ->set('files', [$file]) + ->set('usePassword', true) + ->set('password', 'short') + ->call('createShare') + ->assertHasErrors(['password']); +}); + +test('share password of 8 characters is accepted', function () { + Storage::fake('shares'); + + $file = UploadedFile::fake()->create('file.txt', 100); + + Livewire::test(FileUploader::class) + ->set('files', [$file]) + ->set('usePassword', true) + ->set('password', 'longenough') + ->call('createShare') + ->assertHasNoErrors(['password']); +}); + +// --- is_admin not mass assignable --- + +test('is_admin is not mass assignable on User model', function () { + $user = User::query()->create([ + 'name' => 'Test User', + 'email' => 'mass-assign-test@example.com', + 'password' => bcrypt('password'), + 'is_admin' => true, + ]); + + expect($user->is_admin)->toBeFalsy(); +}); + +// --- Setup wizard guard prevents duplicate admins --- + +test('setup wizard createAdmin is blocked when admin already exists', function () { + $adminCountBefore = User::query()->where('is_admin', true)->count(); + + $this->post(route('setup'), [ + 'name' => 'Second Admin', + 'email' => 'second-admin@example.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ]); + + expect(User::query()->where('email', 'second-admin@example.com')->exists())->toBeFalse(); + expect(User::query()->where('is_admin', true)->count())->toBe($adminCountBefore); +}); + +// --- Content-Disposition sanitization --- + +test('content disposition handles special characters in filename', function () { + Storage::fake('shares'); + + $service = app(ShareService::class); + $encryptionService = app(FileEncryptionService::class); + + $file = UploadedFile::fake()->create('normal.txt', 100); + + $share = $service->createShare([ + ['file' => $file, 'relativePath' => null], + ]); + + $share->load('files'); + $shareFile = $share->files->first(); + + $shareFile->original_name = 'file"with"quotes.txt'; + $shareFile->save(); + + $encryptedDir = Storage::disk('shares')->path($share->token); + if (! is_dir($encryptedDir)) { + mkdir($encryptedDir, 0755, true); + } + $encryptedPath = $encryptedDir.'/'.basename($shareFile->stored_path); + $tempSource = tempnam(sys_get_temp_dir(), 'test'); + file_put_contents($tempSource, 'test content'); + $encryptionService->encryptFile($tempSource, $encryptedPath, $share->encryption_key); + unlink($tempSource); + + $response = $encryptionService->decryptFileStream( + $encryptedPath, + $share->encryption_key, + 'file"with"quotes.txt', + 'text/plain', + 12, + ); + + $contentDisposition = $response->headers->get('Content-Disposition'); + expect($contentDisposition)->not->toContain('file"with"quotes.txt'); + expect($contentDisposition)->toContain('attachment'); +}); + +// --- SVG upload rejected --- + +test('svg upload is rejected for site logo', function () { + $admin = User::query()->where('is_admin', true)->first(); + + $phpMaxMb = \App\Livewire\Admin\AdminSettings::phpMaxUploadMb(); + + Livewire::actingAs($admin) + ->test(\App\Livewire\Admin\AdminSettings::class) + ->set('maxFileSize', $phpMaxMb) + ->set('siteLogo', UploadedFile::fake()->create('logo.svg', 100, 'image/svg+xml')) + ->call('saveSettings') + ->assertHasErrors(['siteLogo']); +}); + +// --- Relative path validation (Zip Slip prevention) --- + +test('relative paths with directory traversal are sanitized', function () { + Storage::fake('shares'); + + $file = UploadedFile::fake()->create('file.txt', 100); + + Livewire::test(FileUploader::class) + ->set('files', [$file]) + ->set('relativePaths', ['../../etc/passwd']) + ->call('createShare') + ->assertRedirectContains('/share/'); + + $share = Share::query()->first(); + $shareFile = $share->files->first(); + expect($shareFile->relative_path)->toBeNull(); +}); + +test('relative paths with absolute paths are sanitized', function () { + Storage::fake('shares'); + + $file = UploadedFile::fake()->create('file.txt', 100); + + Livewire::test(FileUploader::class) + ->set('files', [$file]) + ->set('relativePaths', ['/etc/passwd']) + ->call('createShare') + ->assertRedirectContains('/share/'); + + $share = Share::query()->first(); + $shareFile = $share->files->first(); + expect($shareFile->relative_path)->toBeNull(); +}); + +test('valid relative paths are preserved', function () { + Storage::fake('shares'); + + $file = UploadedFile::fake()->create('file.txt', 100); + + Livewire::test(FileUploader::class) + ->set('files', [$file]) + ->set('relativePaths', ['folder/subfolder/file.txt']) + ->call('createShare') + ->assertRedirectContains('/share/'); + + $share = Share::query()->first(); + $shareFile = $share->files->first(); + expect($shareFile->relative_path)->toBe('folder/subfolder/file.txt'); +}); + +// --- Security headers --- + +test('security headers are present on responses', function () { + $response = $this->get(route('upload')); + + $response->assertHeader('X-Content-Type-Options', 'nosniff'); + $response->assertHeader('X-Frame-Options', 'DENY'); + $response->assertHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + $response->assertHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); +}); + +// --- Token collision retry --- + +test('share service generates unique tokens', function () { + Storage::fake('shares'); + + $service = app(ShareService::class); + + $shares = []; + for ($i = 0; $i < 5; $i++) { + $file = UploadedFile::fake()->create("file{$i}.txt", 100); + $shares[] = $service->createShare([ + ['file' => $file, 'relativePath' => null], + ]); + } + + $tokens = array_map(fn ($s) => $s->token, $shares); + expect(array_unique($tokens))->toHaveCount(5); +}); diff --git a/tests/Feature/SetupWizardTest.php b/tests/Feature/SetupWizardTest.php new file mode 100644 index 0000000..728d036 --- /dev/null +++ b/tests/Feature/SetupWizardTest.php @@ -0,0 +1,56 @@ +where('is_admin', true)->delete(); + + $response = $this->get(route('setup')); + + $response->assertOk(); +}); + +test('setup wizard redirects to upload when admin already exists', function () { + Livewire::test(\App\Livewire\SetupWizard::class) + ->assertRedirect(route('upload')); +}); + +test('setup wizard creates admin user', function () { + User::query()->where('is_admin', true)->delete(); + + Livewire::test(\App\Livewire\SetupWizard::class) + ->set('name', 'Admin User') + ->set('email', 'admin@example.com') + ->set('password', 'password123') + ->set('password_confirmation', 'password123') + ->call('createAdmin') + ->assertRedirect(route('admin.dashboard')); + + $this->assertDatabaseHas('users', [ + 'email' => 'admin@example.com', + 'is_admin' => true, + ]); + + $admin = User::query()->where('email', 'admin@example.com')->first(); + expect($admin)->not->toBeNull(); + expect($admin->is_admin)->toBeTrue(); +}); + +test('setup wizard validates required fields', function () { + User::query()->where('is_admin', true)->delete(); + + Livewire::test(\App\Livewire\SetupWizard::class) + ->set('name', '') + ->set('email', '') + ->set('password', '') + ->call('createAdmin') + ->assertHasErrors(['name', 'email', 'password']); +}); + +test('all routes redirect to setup when no admin exists', function () { + User::query()->where('is_admin', true)->delete(); + + $this->get(route('home'))->assertRedirect(route('setup')); + $this->get(route('login'))->assertRedirect(route('setup')); +}); diff --git a/tests/Feature/ShareDownloadTest.php b/tests/Feature/ShareDownloadTest.php new file mode 100644 index 0000000..6d2e560 --- /dev/null +++ b/tests/Feature/ShareDownloadTest.php @@ -0,0 +1,138 @@ +get(route('share.download', $share)); + + $response->assertOk(); +}); + +test('share download page returns 404 for expired share', function () { + $share = Share::factory()->expired()->create(); + + $response = $this->get(route('share.download', $share)); + + $response->assertNotFound(); +}); + +test('share download page returns 404 when download limit reached', function () { + $share = Share::factory()->withMaxDownloads(1)->create(['download_count' => 1]); + + $response = $this->get(route('share.download', $share)); + + $response->assertNotFound(); +}); + +test('share download page shows password form for password-protected share', function () { + Storage::fake('shares'); + + $share = createShareWithFile('secret-pass'); + + $response = $this->get(route('share.download', $share)); + + $response->assertOk(); + $response->assertSee('password'); +}); + +test('password verification works for protected share', function () { + Storage::fake('shares'); + + $share = createShareWithFile('my-password'); + + Livewire::test(\App\Livewire\ShareDownload::class, ['share' => $share]) + ->assertSet('authenticated', false) + ->set('password', 'my-password') + ->call('verifyPassword') + ->assertSet('authenticated', true) + ->assertHasNoErrors(); +}); + +test('wrong password is rejected', function () { + Storage::fake('shares'); + + $share = createShareWithFile('my-password'); + + Livewire::test(\App\Livewire\ShareDownload::class, ['share' => $share]) + ->set('password', 'wrong-password') + ->call('verifyPassword') + ->assertSet('authenticated', false) + ->assertHasErrors(['password']); +}); + +test('non-password share shows files directly', function () { + Storage::fake('shares'); + + $share = createShareWithFile(); + + Livewire::test(\App\Livewire\ShareDownload::class, ['share' => $share]) + ->assertSet('authenticated', true); +}); + +test('download counter increments on zip download', function () { + Storage::fake('shares'); + + $share = createShareWithFile(); + $share->load('files'); + + $encryptionService = app(FileEncryptionService::class); + $key = $share->encryption_key; + + foreach ($share->files as $file) { + $dir = Storage::disk('shares')->path($share->token); + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + $encryptedPath = $dir.'/'.basename($file->stored_path); + $tempSource = tempnam(sys_get_temp_dir(), 'test'); + file_put_contents($tempSource, 'test content'); + $encryptionService->encryptFile($tempSource, $encryptedPath, $key); + unlink($tempSource); + } + + $this->withSession(['share_password_'.$share->token => null]) + ->get(route('share.download.all', $share)); + + expect($share->fresh()->download_count)->toBe(1); +}); + +test('share auto-deletes after reaching download limit', function () { + Storage::fake('shares'); + + $service = app(ShareService::class); + $file = UploadedFile::fake()->create('file.txt', 100); + + $share = $service->createShare([ + ['file' => $file, 'relativePath' => null], + ], [ + 'max_downloads' => 1, + ]); + + $service->recordDownload($share); + + expect(Share::query()->find($share->id))->toBeNull(); +}); + +/** + * Helper to create a share with an actual encrypted file. + */ +function createShareWithFile(?string $password = null): Share +{ + $service = app(ShareService::class); + $file = UploadedFile::fake()->create('testfile.txt', 100); + + return $service->createShare([ + ['file' => $file, 'relativePath' => null], + ], [ + 'password' => $password, + ]); +} diff --git a/tests/Pest.php b/tests/Pest.php index 40d096b..bdcb1bc 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -13,6 +13,10 @@ pest()->extend(Tests\TestCase::class) ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) + ->beforeEach(function () { + // EnsureSetupComplete middleware redirects to /setup unless an admin exists. + \App\Models\User::factory()->admin()->create(['email' => 'admin-setup@test.com']); + }) ->in('Feature'); /* diff --git a/tests/Unit/FileEncryptionServiceTest.php b/tests/Unit/FileEncryptionServiceTest.php new file mode 100644 index 0000000..61d6fe6 --- /dev/null +++ b/tests/Unit/FileEncryptionServiceTest.php @@ -0,0 +1,256 @@ +service = new FileEncryptionService; + $this->tempDir = sys_get_temp_dir().'/sealshare-test-'.uniqid(); + mkdir($this->tempDir, 0755, true); +}); + +afterEach(function () { + if (is_dir($this->tempDir)) { + array_map('unlink', glob($this->tempDir.'/*')); + rmdir($this->tempDir); + } +}); + +test('encrypt and decrypt round-trip works', function () { + $sourcePath = $this->tempDir.'/source.txt'; + $encryptedPath = $this->tempDir.'/encrypted.enc'; + $content = 'Hello, World! This is a secret message.'; + + file_put_contents($sourcePath, $content); + + $key = $this->service->generateRandomKey(); + + $this->service->encryptFile($sourcePath, $encryptedPath, $key); + + expect(file_exists($encryptedPath))->toBeTrue(); + expect(file_get_contents($encryptedPath))->not->toBe($content); + + $decrypted = $this->service->decryptFile($encryptedPath, $key); + + expect($decrypted)->toBe($content); +}); + +test('decrypt with wrong key fails', function () { + $sourcePath = $this->tempDir.'/source.txt'; + $encryptedPath = $this->tempDir.'/encrypted.enc'; + + file_put_contents($sourcePath, 'Secret data'); + + $correctKey = $this->service->generateRandomKey(); + $wrongKey = $this->service->generateRandomKey(); + + $this->service->encryptFile($sourcePath, $encryptedPath, $correctKey); + + $this->service->decryptFile($encryptedPath, $wrongKey); +})->throws(RuntimeException::class, 'Decryption failed'); + +test('derive key produces consistent results', function () { + $password = 'my-secure-password'; + $salt = $this->service->generateSalt(); + + $key1 = $this->service->deriveKey($password, $salt); + $key2 = $this->service->deriveKey($password, $salt); + + expect($key1)->toBe($key2); +}); + +test('derive key with different passwords produces different keys', function () { + $salt = $this->service->generateSalt(); + + $key1 = $this->service->deriveKey('password1', $salt); + $key2 = $this->service->deriveKey('password2', $salt); + + expect($key1)->not->toBe($key2); +}); + +test('derive key with different salts produces different keys', function () { + $password = 'same-password'; + + $key1 = $this->service->deriveKey($password, $this->service->generateSalt()); + $key2 = $this->service->deriveKey($password, $this->service->generateSalt()); + + expect($key1)->not->toBe($key2); +}); + +test('generate random key returns 64 char hex string', function () { + $key = $this->service->generateRandomKey(); + + expect(strlen($key))->toBe(64); + expect(ctype_xdigit($key))->toBeTrue(); +}); + +test('generate salt returns 64 char hex string', function () { + $salt = $this->service->generateSalt(); + + expect(strlen($salt))->toBe(64); + expect(ctype_xdigit($salt))->toBeTrue(); +}); + +test('password-derived key encrypt/decrypt round-trip works', function () { + $sourcePath = $this->tempDir.'/source.txt'; + $encryptedPath = $this->tempDir.'/encrypted.enc'; + $content = 'Password protected content'; + + file_put_contents($sourcePath, $content); + + $password = 'user-password'; + $salt = $this->service->generateSalt(); + $key = bin2hex($this->service->deriveKey($password, $salt)); + + $this->service->encryptFile($sourcePath, $encryptedPath, $key); + $decrypted = $this->service->decryptFile($encryptedPath, $key); + + expect($decrypted)->toBe($content); +}); + +test('decrypt file stream returns streamed response', function () { + $sourcePath = $this->tempDir.'/source.txt'; + $encryptedPath = $this->tempDir.'/encrypted.enc'; + $content = 'Streamed content'; + + file_put_contents($sourcePath, $content); + + $key = $this->service->generateRandomKey(); + $this->service->encryptFile($sourcePath, $encryptedPath, $key); + + $response = $this->service->decryptFileStream($encryptedPath, $key, 'test.txt', 'text/plain'); + + expect($response)->toBeInstanceOf(Symfony\Component\HttpFoundation\StreamedResponse::class); + expect($response->headers->get('Content-Type'))->toBe('text/plain'); + expect($response->headers->get('Content-Disposition'))->toContain('test.txt'); +}); + +test('chunked file has SEALCHK1 magic header', function () { + $sourcePath = $this->tempDir.'/source.txt'; + $encryptedPath = $this->tempDir.'/encrypted.enc'; + + file_put_contents($sourcePath, 'test content'); + + $key = $this->service->generateRandomKey(); + $this->service->encryptFile($sourcePath, $encryptedPath, $key); + + $header = file_get_contents($encryptedPath, false, null, 0, 8); + + expect($header)->toBe('SEALCHK1'); +}); + +test('multi-chunk round-trip works', function () { + $sourcePath = $this->tempDir.'/large.bin'; + $encryptedPath = $this->tempDir.'/large.enc'; + + // Create a file larger than one 4 MB chunk (5 MB) + $chunkSize = 4 * 1024 * 1024; + $content = random_bytes($chunkSize + (1024 * 1024)); + + file_put_contents($sourcePath, $content); + + $key = $this->service->generateRandomKey(); + $this->service->encryptFile($sourcePath, $encryptedPath, $key); + $decrypted = $this->service->decryptFile($encryptedPath, $key); + + expect($decrypted)->toBe($content); +}); + +test('exact chunk boundary round-trip works', function () { + $sourcePath = $this->tempDir.'/exact.bin'; + $encryptedPath = $this->tempDir.'/exact.enc'; + + // Create a file exactly equal to one chunk (4 MB) + $content = random_bytes(4 * 1024 * 1024); + + file_put_contents($sourcePath, $content); + + $key = $this->service->generateRandomKey(); + $this->service->encryptFile($sourcePath, $encryptedPath, $key); + $decrypted = $this->service->decryptFile($encryptedPath, $key); + + expect($decrypted)->toBe($content); +}); + +test('empty file round-trip works', function () { + $sourcePath = $this->tempDir.'/empty.bin'; + $encryptedPath = $this->tempDir.'/empty.enc'; + + file_put_contents($sourcePath, ''); + + $key = $this->service->generateRandomKey(); + $this->service->encryptFile($sourcePath, $encryptedPath, $key); + $decrypted = $this->service->decryptFile($encryptedPath, $key); + + expect($decrypted)->toBe(''); +}); + +test('legacy format backward compatibility', function () { + $sourcePath = $this->tempDir.'/source.txt'; + $encryptedPath = $this->tempDir.'/legacy.enc'; + $content = 'Legacy encrypted content'; + + file_put_contents($sourcePath, $content); + + $key = $this->service->generateRandomKey(); + $binaryKey = hex2bin($key); + + // Manually create a legacy format file: [nonce][tag][ciphertext] + $nonce = random_bytes(12); + $tag = ''; + $ciphertext = openssl_encrypt($content, 'aes-256-gcm', $binaryKey, OPENSSL_RAW_DATA, $nonce, $tag, '', 16); + file_put_contents($encryptedPath, $nonce.$tag.$ciphertext); + + $decrypted = $this->service->decryptFile($encryptedPath, $key); + + expect($decrypted)->toBe($content); +}); + +test('wrong key on chunked file throws exception', function () { + $sourcePath = $this->tempDir.'/source.txt'; + $encryptedPath = $this->tempDir.'/encrypted.enc'; + + file_put_contents($sourcePath, 'Chunked secret data'); + + $correctKey = $this->service->generateRandomKey(); + $wrongKey = $this->service->generateRandomKey(); + + $this->service->encryptFile($sourcePath, $encryptedPath, $correctKey); + + $this->service->decryptFile($encryptedPath, $wrongKey); +})->throws(RuntimeException::class, 'Decryption failed'); + +test('decryptFileToCallback returns working stream resource', function () { + $sourcePath = $this->tempDir.'/source.txt'; + $encryptedPath = $this->tempDir.'/encrypted.enc'; + $content = 'Callback decrypted content'; + + file_put_contents($sourcePath, $content); + + $key = $this->service->generateRandomKey(); + $this->service->encryptFile($sourcePath, $encryptedPath, $key); + + $callback = $this->service->decryptFileToCallback($encryptedPath, $key); + $resource = $callback(); + + expect(is_resource($resource))->toBeTrue(); + + $decrypted = stream_get_contents($resource); + fclose($resource); + + expect($decrypted)->toBe($content); +}); + +test('decrypt file stream with file size sets content-length header', function () { + $sourcePath = $this->tempDir.'/source.txt'; + $encryptedPath = $this->tempDir.'/encrypted.enc'; + $content = 'Content with known size'; + + file_put_contents($sourcePath, $content); + + $key = $this->service->generateRandomKey(); + $this->service->encryptFile($sourcePath, $encryptedPath, $key); + + $response = $this->service->decryptFileStream($encryptedPath, $key, 'test.txt', 'text/plain', strlen($content)); + + expect($response->headers->get('Content-Length'))->toBe((string) strlen($content)); +}); diff --git a/tests/Unit/ShareServiceTest.php b/tests/Unit/ShareServiceTest.php new file mode 100644 index 0000000..28b0907 --- /dev/null +++ b/tests/Unit/ShareServiceTest.php @@ -0,0 +1,186 @@ +extend(Tests\TestCase::class) + ->use(Illuminate\Foundation\Testing\RefreshDatabase::class); + +beforeEach(function () { + Storage::fake('shares'); + $this->service = app(ShareService::class); +}); + +test('create share without password stores encryption key', function () { + $file = UploadedFile::fake()->create('document.pdf', 1024); + + $share = $this->service->createShare([ + ['file' => $file, 'relativePath' => null], + ]); + + expect($share)->toBeInstanceOf(Share::class); + expect($share->token)->toHaveLength(16); + expect($share->password)->toBeNull(); + expect($share->encryption_key)->not->toBeNull(); + expect($share->encryption_salt)->not->toBeNull(); + expect($share->files)->toHaveCount(1); + expect($share->files->first()->original_name)->toBe('document.pdf'); +}); + +test('create share with password does not store encryption key', function () { + $file = UploadedFile::fake()->create('secret.txt', 512); + + $share = $this->service->createShare([ + ['file' => $file, 'relativePath' => null], + ], [ + 'password' => 'my-password', + ]); + + expect($share->password)->not->toBeNull(); + expect($share->encryption_key)->toBeNull(); + expect(\Illuminate\Support\Facades\Hash::check('my-password', $share->password))->toBeTrue(); +}); + +test('create share with options sets expiration and max downloads', function () { + $file = UploadedFile::fake()->create('file.txt', 256); + + $share = $this->service->createShare([ + ['file' => $file, 'relativePath' => null], + ], [ + 'expires_at' => now()->addDay(), + 'max_downloads' => 5, + ]); + + expect($share->expires_at)->not->toBeNull(); + expect($share->max_downloads)->toBe(5); +}); + +test('create share with multiple files', function () { + $file1 = UploadedFile::fake()->create('file1.txt', 100); + $file2 = UploadedFile::fake()->create('file2.txt', 200); + + $share = $this->service->createShare([ + ['file' => $file1, 'relativePath' => 'folder/file1.txt'], + ['file' => $file2, 'relativePath' => 'folder/file2.txt'], + ]); + + expect($share->files)->toHaveCount(2); + expect($share->files->first()->relative_path)->toBe('folder/file1.txt'); +}); + +test('delete share removes files and database records', function () { + $file = UploadedFile::fake()->create('file.txt', 100); + + $share = $this->service->createShare([ + ['file' => $file, 'relativePath' => null], + ]); + + $shareId = $share->id; + $token = $share->token; + + $this->service->deleteShare($share); + + expect(Share::query()->find($shareId))->toBeNull(); + expect(ShareFile::query()->where('share_id', $shareId)->count())->toBe(0); + expect(Storage::disk('shares')->directories())->not->toContain($token); +}); + +test('verify password returns true for correct password', function () { + $file = UploadedFile::fake()->create('file.txt', 100); + + $share = $this->service->createShare([ + ['file' => $file, 'relativePath' => null], + ], [ + 'password' => 'correct-password', + ]); + + expect($this->service->verifyPassword($share, 'correct-password'))->toBeTrue(); + expect($this->service->verifyPassword($share, 'wrong-password'))->toBeFalse(); +}); + +test('verify password returns true for non-password share', function () { + $file = UploadedFile::fake()->create('file.txt', 100); + + $share = $this->service->createShare([ + ['file' => $file, 'relativePath' => null], + ]); + + expect($this->service->verifyPassword($share, 'any'))->toBeTrue(); +}); + +test('record download increments counter', function () { + $share = Share::factory()->create(['download_count' => 0]); + + $this->service->recordDownload($share); + + expect($share->fresh()->download_count)->toBe(1); +}); + +test('record download auto-deletes when limit reached', function () { + $share = Share::factory()->withMaxDownloads(1)->create(['download_count' => 0]); + + $this->service->recordDownload($share); + + expect(Share::query()->find($share->id))->toBeNull(); +}); + +test('get total used space sums share sizes', function () { + Share::factory()->create(['total_size' => 1000]); + Share::factory()->create(['total_size' => 2000]); + + expect($this->service->getTotalUsedSpace())->toBe(3000); +}); + +test('is storage full checks against quota', function () { + Setting::set('max_storage_quota', 1000); + + Share::factory()->create(['total_size' => 999]); + expect($this->service->isStorageFull())->toBeFalse(); + + Share::factory()->create(['total_size' => 1]); + expect($this->service->isStorageFull())->toBeTrue(); +}); + +test('get decryption key returns stored key for non-password share', function () { + $file = UploadedFile::fake()->create('file.txt', 100); + + $share = $this->service->createShare([ + ['file' => $file, 'relativePath' => null], + ]); + + $key = $this->service->getDecryptionKey($share); + + expect($key)->not->toBeNull(); + expect(strlen($key))->toBe(64); +}); + +test('get decryption key derives key for password share', function () { + $file = UploadedFile::fake()->create('file.txt', 100); + + $share = $this->service->createShare([ + ['file' => $file, 'relativePath' => null], + ], [ + 'password' => 'test-password', + ]); + + $key = $this->service->getDecryptionKey($share, 'test-password'); + + expect($key)->not->toBeNull(); + expect(strlen($key))->toBe(64); +}); + +test('get decryption key throws for password share without password', function () { + $file = UploadedFile::fake()->create('file.txt', 100); + + $share = $this->service->createShare([ + ['file' => $file, 'relativePath' => null], + ], [ + 'password' => 'test-password', + ]); + + $this->service->getDecryptionKey($share); +})->throws(RuntimeException::class, 'Password required'); diff --git a/vite.config.js b/vite.config.js index f65249e..f741746 100644 --- a/vite.config.js +++ b/vite.config.js @@ -13,7 +13,12 @@ export default defineConfig({ tailwindcss(), ], server: { + host: '0.0.0.0', + port: 5173, cors: true, + hmr: { + host: 'localhost', + }, watch: { ignored: ['**/storage/framework/views/**'], },