Add SealShare file sharing application with encryption, branding, auth, Docker, and Octane support
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@@ -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.
|
||||
|
||||
<!-- Basic Button -->
|
||||
```blade
|
||||
<flux:button variant="primary">Click me</flux:button>
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
<!-- Icon Button -->
|
||||
```blade
|
||||
<flux:button icon="arrow-down-tray">Export</flux:button>
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
<!-- Form Field -->
|
||||
```blade
|
||||
<flux:field>
|
||||
<flux:label>Email</flux:label>
|
||||
<flux:input type="email" wire:model="email" />
|
||||
<flux:error name="email" />
|
||||
</flux:field>
|
||||
```
|
||||
|
||||
### Modals
|
||||
|
||||
<!-- Modal -->
|
||||
```blade
|
||||
<flux:modal wire:model="showModal">
|
||||
<flux:heading>Title</flux:heading>
|
||||
<p>Content</p>
|
||||
</flux:modal>
|
||||
```
|
||||
|
||||
## 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
|
||||
@@ -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-First Config -->
|
||||
```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:
|
||||
|
||||
<!-- v4 Import Syntax -->
|
||||
```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:
|
||||
|
||||
<!-- Gap Utilities -->
|
||||
```html
|
||||
<div class="flex gap-8">
|
||||
<div>Item 1</div>
|
||||
<div>Item 2</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 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:
|
||||
|
||||
<!-- Dark Mode -->
|
||||
```html
|
||||
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||
Content adapts to color scheme
|
||||
</div>
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Flexbox Layout
|
||||
|
||||
<!-- Flexbox Layout -->
|
||||
```html
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>Left content</div>
|
||||
<div>Right content</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Grid Layout
|
||||
|
||||
<!-- Grid Layout -->
|
||||
```html
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div>Card 1</div>
|
||||
<div>Card 2</div>
|
||||
<div>Card 3</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,3 +21,7 @@ yarn-error.log
|
||||
/.nova
|
||||
/.vscode
|
||||
/.zed
|
||||
|
||||
**/caddy
|
||||
frankenphp
|
||||
frankenphp-worker.php
|
||||
|
||||
@@ -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 `<flux:*>` 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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Share;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CleanupExpiredShares extends Command
|
||||
{
|
||||
protected $signature = 'shares:cleanup';
|
||||
|
||||
protected $description = 'Delete expired shares and shares that have reached their download limit';
|
||||
|
||||
public function handle(ShareService $shareService): int
|
||||
{
|
||||
$expiredShares = Share::query()
|
||||
->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Share;
|
||||
use App\Models\ShareFile;
|
||||
use App\Services\FileEncryptionService;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use ZipStream\ZipStream;
|
||||
|
||||
class DownloadController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private FileEncryptionService $encryptionService,
|
||||
private ShareService $shareService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Download all files as a ZIP archive.
|
||||
*/
|
||||
public function download(Share $share): StreamedResponse
|
||||
{
|
||||
abort_if($share->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureAdmin
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->user() || ! $request->user()->is_admin) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureSetupComplete
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! User::query()->where('is_admin', true)->exists() && ! $request->is('setup', 'setup/*', 'livewire*')) {
|
||||
return redirect()->route('setup');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SecurityHeaders
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$response = $next($request);
|
||||
|
||||
$response->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SystemPasswordGate
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$systemPassword = Setting::get('system_password');
|
||||
|
||||
if (! $systemPassword) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($request->session()->get('system_password_verified') === true) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
return redirect()->route('system-password');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\Share;
|
||||
use App\Models\ShareFile;
|
||||
use App\Services\ShareService;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class AdminDashboard extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
/** @var array<string, string> */
|
||||
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<string, array<string, string|bool>>
|
||||
*/
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class AdminSettings extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public string $systemPassword = '';
|
||||
|
||||
public string $defaultExpiration = '';
|
||||
|
||||
public int $maxFileSize = 100;
|
||||
|
||||
public int $maxStorageQuota = 20;
|
||||
|
||||
public int $maxFilesPerShare = 50;
|
||||
|
||||
public int $maxSizePerShare = 2;
|
||||
|
||||
public bool $allowNeverExpire = false;
|
||||
|
||||
public string $siteTitle = '';
|
||||
|
||||
public string $siteDescription = '';
|
||||
|
||||
public $siteLogo;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class FileUploader extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
/** @var array<int, \Livewire\Features\SupportFileUploads\TemporaryUploadedFile> */
|
||||
public array $files = [];
|
||||
|
||||
/** @var array<int, string|null> */
|
||||
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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.auth')]
|
||||
class SetupWizard extends Component
|
||||
{
|
||||
#[Validate('required|string|max:255')]
|
||||
public string $name = '';
|
||||
|
||||
#[Validate('required|string|email|max:255|unique:users')]
|
||||
public string $email = '';
|
||||
|
||||
#[Validate('required|string|min:8|confirmed')]
|
||||
public string $password = '';
|
||||
|
||||
public string $password_confirmation = '';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
if (User::query()->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Share;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class ShareCreated extends Component
|
||||
{
|
||||
public Share $share;
|
||||
|
||||
public function mount(Share $share): void
|
||||
{
|
||||
$this->share = $share;
|
||||
}
|
||||
|
||||
public function render(): mixed
|
||||
{
|
||||
return view('livewire.share-created');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\Share;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class ShareDownload extends Component
|
||||
{
|
||||
public Share $share;
|
||||
|
||||
public bool $authenticated = false;
|
||||
|
||||
#[Validate('required|string')]
|
||||
public string $password = '';
|
||||
|
||||
public function mount(Share $share): void
|
||||
{
|
||||
$this->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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.auth')]
|
||||
class SystemPasswordPrompt extends Component
|
||||
{
|
||||
#[Validate('required|string')]
|
||||
public string $password = '';
|
||||
|
||||
public function verify(): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Setting extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'key',
|
||||
'value',
|
||||
];
|
||||
|
||||
public static function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$setting = static::query()->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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Share extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'token',
|
||||
'password',
|
||||
'encryption_key',
|
||||
'encryption_salt',
|
||||
'expires_at',
|
||||
'max_downloads',
|
||||
'download_count',
|
||||
'total_size',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'expires_at' => 'datetime',
|
||||
'max_downloads' => 'integer',
|
||||
'download_count' => 'integer',
|
||||
'total_size' => 'integer',
|
||||
'encryption_key' => 'encrypted',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<ShareFile, $this>
|
||||
*/
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ShareFile extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'share_id',
|
||||
'original_name',
|
||||
'relative_path',
|
||||
'stored_path',
|
||||
'file_size',
|
||||
'mime_type',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'file_size' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Share, $this>
|
||||
*/
|
||||
public function share(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Share::class);
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ class User extends Authenticatable
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_admin' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Closure;
|
||||
use Generator;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class FileEncryptionService
|
||||
{
|
||||
private const CIPHER = 'aes-256-gcm';
|
||||
|
||||
private const PBKDF2_ITERATIONS = 100000;
|
||||
|
||||
private const KEY_LENGTH = 32;
|
||||
|
||||
private const NONCE_LENGTH = 12;
|
||||
|
||||
private const TAG_LENGTH = 16;
|
||||
|
||||
private const MAGIC_HEADER = 'SEALCHK1';
|
||||
|
||||
private const DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024; // 4 MB
|
||||
|
||||
/**
|
||||
* Derive an encryption key from a password and salt using PBKDF2-SHA256.
|
||||
*/
|
||||
public function deriveKey(string $password, string $salt): string
|
||||
{
|
||||
return hash_pbkdf2('sha256', $password, hex2bin($salt), self::PBKDF2_ITERATIONS, self::KEY_LENGTH, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random hex salt (32 bytes = 64 hex chars).
|
||||
*/
|
||||
public function generateSalt(): string
|
||||
{
|
||||
return bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random encryption key (32 bytes, returned as hex).
|
||||
*/
|
||||
public function generateRandomKey(): string
|
||||
{
|
||||
return bin2hex(random_bytes(self::KEY_LENGTH));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a file using chunked AES-256-GCM.
|
||||
*
|
||||
* Output format:
|
||||
* [8 bytes: "SEALCHK1" magic]
|
||||
* [4 bytes: chunk size, uint32 big-endian]
|
||||
* [12 bytes: base nonce]
|
||||
* Per chunk:
|
||||
* [16 bytes: GCM auth tag]
|
||||
* [N bytes: ciphertext (up to chunk_size)]
|
||||
*/
|
||||
public function encryptFile(string $sourcePath, string $destPath, string $key): void
|
||||
{
|
||||
$source = fopen($sourcePath, 'rb');
|
||||
|
||||
if ($source === false) {
|
||||
throw new RuntimeException("Cannot read source file: {$sourcePath}");
|
||||
}
|
||||
|
||||
$dest = fopen($destPath, 'wb');
|
||||
|
||||
if ($dest === false) {
|
||||
fclose($source);
|
||||
|
||||
throw new RuntimeException("Cannot write encrypted file: {$destPath}");
|
||||
}
|
||||
|
||||
try {
|
||||
$binaryKey = $this->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<int, string>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\Share;
|
||||
use App\Models\ShareFile;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class ShareService
|
||||
{
|
||||
public function __construct(
|
||||
private FileEncryptionService $encryptionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new share with encrypted files.
|
||||
*
|
||||
* @param array<int, array{file: UploadedFile, relativePath: string|null}> $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);
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,9 @@
|
||||
],
|
||||
"sail": false,
|
||||
"skills": [
|
||||
"fluxui-development",
|
||||
"livewire-development",
|
||||
"pest-testing",
|
||||
"tailwindcss-development",
|
||||
"developing-with-fortify"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\EnsureAdmin;
|
||||
use App\Http\Middleware\EnsureSetupComplete;
|
||||
use App\Http\Middleware\SecurityHeaders;
|
||||
use App\Http\Middleware\SystemPasswordGate;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
@@ -11,7 +15,17 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->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 {
|
||||
//
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -13,7 +13,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'name' => env('APP_NAME', 'Laravel'),
|
||||
'name' => env('APP_NAME', 'SealShare'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -144,7 +144,7 @@ return [
|
||||
*/
|
||||
|
||||
'features' => [
|
||||
Features::registration(),
|
||||
// Features::registration(), // Disabled - admin created via setup wizard
|
||||
Features::resetPasswords(),
|
||||
Features::emailVerification(),
|
||||
Features::twoFactorAuthentication([
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Component Locations
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| This value sets the root directories that'll be used to resolve view-based
|
||||
| components like single and multi-file components. The make command will
|
||||
| use the first directory in this array to add new component files to.
|
||||
|
|
||||
*/
|
||||
|
||||
'component_locations' => [
|
||||
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
|
||||
| <head> and <body> 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
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
use Laravel\Octane\Contracts\OperationTerminated;
|
||||
use Laravel\Octane\Events\RequestHandled;
|
||||
use Laravel\Octane\Events\RequestReceived;
|
||||
use Laravel\Octane\Events\RequestTerminated;
|
||||
use Laravel\Octane\Events\TaskReceived;
|
||||
use Laravel\Octane\Events\TaskTerminated;
|
||||
use Laravel\Octane\Events\TickReceived;
|
||||
use Laravel\Octane\Events\TickTerminated;
|
||||
use Laravel\Octane\Events\WorkerErrorOccurred;
|
||||
use Laravel\Octane\Events\WorkerStarting;
|
||||
use Laravel\Octane\Events\WorkerStopping;
|
||||
use Laravel\Octane\Listeners\CloseMonologHandlers;
|
||||
use Laravel\Octane\Listeners\CollectGarbage;
|
||||
use Laravel\Octane\Listeners\DisconnectFromDatabases;
|
||||
use Laravel\Octane\Listeners\EnsureUploadedFilesAreValid;
|
||||
use Laravel\Octane\Listeners\EnsureUploadedFilesCanBeMoved;
|
||||
use Laravel\Octane\Listeners\FlushOnce;
|
||||
use Laravel\Octane\Listeners\FlushTemporaryContainerInstances;
|
||||
use Laravel\Octane\Listeners\FlushUploadedFiles;
|
||||
use Laravel\Octane\Listeners\ReportException;
|
||||
use Laravel\Octane\Listeners\StopWorkerIfNecessary;
|
||||
use Laravel\Octane\Octane;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Octane Server
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the default "server" that will be used by Octane
|
||||
| when starting, restarting, or stopping your server via the CLI. You
|
||||
| are free to change this to the supported server of your choosing.
|
||||
|
|
||||
| Supported: "roadrunner", "swoole", "frankenphp"
|
||||
|
|
||||
*/
|
||||
|
||||
'server' => 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),
|
||||
|
||||
];
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Share;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<Share>
|
||||
*/
|
||||
class ShareFactory extends Factory
|
||||
{
|
||||
protected $model = Share::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Share;
|
||||
use App\Models\ShareFile;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<ShareFile>
|
||||
*/
|
||||
class ShareFileFactory extends Factory
|
||||
{
|
||||
protected $model = ShareFile::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('shares', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('share_files', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('settings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('key')->unique();
|
||||
$table->text('value')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('settings');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <<EOF
|
||||
upload_max_filesize = ${PHP_UPLOAD_MAX_FILESIZE:-4G}
|
||||
post_max_size = ${PHP_POST_MAX_SIZE:-4G}
|
||||
max_execution_time = ${PHP_MAX_EXECUTION_TIME:-300}
|
||||
max_input_time = ${PHP_MAX_INPUT_TIME:-300}
|
||||
memory_limit = ${PHP_MEMORY_LIMIT:-512M}
|
||||
EOF
|
||||
|
||||
echo "[dev] Installing Node dependencies..."
|
||||
npm install 2>&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
|
||||
@@ -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"]
|
||||
@@ -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 <<EOF
|
||||
upload_max_filesize = ${PHP_UPLOAD_MAX_FILESIZE:-4G}
|
||||
post_max_size = ${PHP_POST_MAX_SIZE:-4G}
|
||||
max_execution_time = ${PHP_MAX_EXECUTION_TIME:-300}
|
||||
max_input_time = ${PHP_MAX_INPUT_TIME:-300}
|
||||
memory_limit = ${PHP_MEMORY_LIMIT:-512M}
|
||||
EOF
|
||||
|
||||
echo "[entrypoint] Running database migrations..."
|
||||
php artisan migrate --force
|
||||
|
||||
echo "[entrypoint] Creating storage link..."
|
||||
php artisan storage:link --force
|
||||
|
||||
echo "[entrypoint] Caching configuration..."
|
||||
php artisan config:cache
|
||||
php artisan route:cache
|
||||
php artisan view:cache
|
||||
|
||||
echo "[entrypoint] Starting Octane (FrankenPHP)..."
|
||||
exec php artisan octane:frankenphp --host=0.0.0.0 --port=80
|
||||
@@ -0,0 +1,9 @@
|
||||
; PHP settings for file uploads.
|
||||
; These are default values — overridden at runtime by the entrypoint
|
||||
; when PHP_UPLOAD_MAX_FILESIZE / PHP_POST_MAX_SIZE / etc. env vars are set.
|
||||
|
||||
upload_max_filesize = 4G
|
||||
post_max_size = 4G
|
||||
max_execution_time = 300
|
||||
max_input_time = 300
|
||||
memory_limit = 512M
|
||||
@@ -1,18 +1,23 @@
|
||||
{
|
||||
"name": "FileShare",
|
||||
"name": "app",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"chokidar": "^5.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-linux-x64-gnu": "4.9.5",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "^4.0.1",
|
||||
@@ -1068,6 +1073,30 @@
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
|
||||
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
|
||||
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/alpinejs": {
|
||||
"version": "3.15.8",
|
||||
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.8.tgz",
|
||||
"integrity": "sha512-zxIfCRTBGvF1CCLIOMQOxAyBuqibxSEwS6Jm1a3HGA9rgrJVcjEWlwLcQTVGAWGS8YhAsTRLVrtQ5a5QT9bSSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "~3.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
@@ -1248,6 +1277,22 @@
|
||||
"node": ">=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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
upload_max_filesize = 4G
|
||||
post_max_size = 4G
|
||||
max_execution_time = 300
|
||||
max_input_time = 300
|
||||
memory_limit = 512M
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 9.4 KiB |
@@ -1,3 +1,7 @@
|
||||
<svg width="166" height="166" viewBox="0 0 166 166" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M162.041 38.7592C162.099 38.9767 162.129 39.201 162.13 39.4264V74.4524C162.13 74.9019 162.011 75.3435 161.786 75.7325C161.561 76.1216 161.237 76.4442 160.847 76.6678L131.462 93.5935V127.141C131.462 128.054 130.977 128.897 130.186 129.357L68.8474 164.683C68.707 164.763 68.5538 164.814 68.4007 164.868C68.3432 164.887 68.289 164.922 68.2284 164.938C67.7996 165.051 67.3489 165.051 66.9201 164.938C66.8499 164.919 66.7861 164.881 66.7191 164.855C66.5787 164.804 66.4319 164.76 66.2979 164.683L4.97219 129.357C4.58261 129.133 4.2589 128.81 4.0337 128.421C3.8085 128.032 3.68976 127.591 3.68945 127.141L3.68945 22.0634C3.68945 21.8336 3.72136 21.6101 3.7788 21.393C3.79794 21.3196 3.84262 21.2526 3.86814 21.1791C3.91601 21.0451 3.96068 20.9078 4.03088 20.7833C4.07874 20.7003 4.14894 20.6333 4.20638 20.5566C4.27977 20.4545 4.34678 20.3491 4.43293 20.2598C4.50632 20.1863 4.60205 20.1321 4.68501 20.0682C4.77755 19.9916 4.86051 19.9086 4.96581 19.848L35.6334 2.18492C36.0217 1.96139 36.4618 1.84375 36.9098 1.84375C37.3578 1.84375 37.7979 1.96139 38.1862 2.18492L68.8506 19.848H68.857C68.9591 19.9118 69.0452 19.9916 69.1378 20.065C69.2207 20.1289 69.3133 20.1863 69.3867 20.2566C69.476 20.3491 69.5398 20.4545 69.6164 20.5566C69.6707 20.6333 69.7441 20.7003 69.7887 20.7833C69.8621 20.911 69.9036 21.0451 69.9546 21.1791C69.9802 21.2526 70.0248 21.3196 70.044 21.3962C70.1027 21.6138 70.1328 21.8381 70.1333 22.0634V87.6941L95.686 72.9743V39.4232C95.686 39.1997 95.7179 38.9731 95.7753 38.7592C95.7977 38.6826 95.8391 38.6155 95.8647 38.5421C95.9157 38.408 95.9604 38.2708 96.0306 38.1463C96.0785 38.0633 96.1487 37.9962 96.2029 37.9196C96.2795 37.8175 96.3433 37.7121 96.4326 37.6227C96.506 37.5493 96.5986 37.495 96.6815 37.4312C96.7773 37.3546 96.8602 37.2716 96.9623 37.2109L127.633 19.5479C128.021 19.324 128.461 19.2062 128.91 19.2062C129.358 19.2062 129.798 19.324 130.186 19.5479L160.85 37.2109C160.959 37.2748 161.042 37.3546 161.137 37.428C161.217 37.4918 161.31 37.5493 161.383 37.6195C161.473 37.7121 161.536 37.8175 161.613 37.9196C161.67 37.9962 161.741 38.0633 161.785 38.1463C161.859 38.2708 161.9 38.408 161.951 38.5421C161.98 38.6155 162.021 38.6826 162.041 38.7592ZM157.018 72.9743V43.8477L146.287 50.028L131.462 58.5675V87.6941L157.021 72.9743H157.018ZM126.354 125.663V96.5176L111.771 104.85L70.1301 128.626V158.046L126.354 125.663ZM8.80126 26.4848V125.663L65.0183 158.043V128.629L35.6494 112L35.6398 111.994L35.6271 111.988C35.5281 111.93 35.4452 111.847 35.3526 111.777C35.2729 111.713 35.1803 111.662 35.1101 111.592L35.1038 111.582C35.0208 111.502 34.9634 111.403 34.8932 111.314C34.8293 111.228 34.7528 111.154 34.7017 111.065L34.6985 111.055C34.6411 110.96 34.606 110.845 34.5645 110.736C34.523 110.64 34.4688 110.551 34.4432 110.449C34.4113 110.328 34.4049 110.197 34.3922 110.072C34.3794 109.976 34.3539 109.881 34.3539 109.785V109.778V41.2045L19.5322 32.6619L8.80126 26.4848ZM36.913 7.35007L11.3635 22.0634L36.9066 36.7768L62.4529 22.0602L36.9066 7.35007H36.913ZM50.1999 99.1736L65.0215 90.6374V26.4848L54.2906 32.6651L39.4657 41.2045V105.357L50.1999 99.1736ZM128.91 24.713L103.363 39.4264L128.91 54.1397L154.453 39.4232L128.91 24.713ZM126.354 58.5675L111.529 50.028L100.798 43.8477V72.9743L115.619 81.5106L126.354 87.6941V58.5675ZM67.5711 124.205L105.042 102.803L123.772 92.109L98.2451 77.4053L68.8538 94.3341L42.0663 109.762L67.5711 124.205Z" fill="#FF2D20"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" width="24" height="24">
|
||||
<!-- Document outline with folded corner -->
|
||||
<path d="M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Z" fill="#6366f1" stroke="#4f46e5" stroke-width="0.5" />
|
||||
<path d="M14 2v5a1 1 0 0 0 1 1h5" fill="#818cf8" stroke="#4f46e5" stroke-width="0.5" />
|
||||
<!-- Upload arrow -->
|
||||
<path d="M12 17v-6m0 0-2.5 2.5M12 11l2.5 2.5" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 535 B |
@@ -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;
|
||||
} */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 42" {{ $attributes }}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M17.2 5.633 8.6.855 0 5.633v26.51l16.2 9 16.2-9v-8.442l7.6-4.223V9.856l-8.6-4.777-8.6 4.777V18.3l-5.6 3.111V5.633ZM38 18.301l-5.6 3.11v-6.157l5.6-3.11V18.3Zm-1.06-7.856-5.54 3.078-5.54-3.079 5.54-3.078 5.54 3.079ZM24.8 18.3v-6.157l5.6 3.111v6.158L24.8 18.3Zm-1 1.732 5.54 3.078-13.14 7.302-5.54-3.078 13.14-7.3v-.002Zm-16.2 7.89 7.6 4.222V38.3L2 30.966V7.92l5.6 3.111v16.892ZM8.6 9.3 3.06 6.222 8.6 3.143l5.54 3.08L8.6 9.3Zm21.8 15.51-13.2 7.334V38.3l13.2-7.334v-6.156ZM9.6 11.034l5.6-3.11v14.6l-5.6 3.11v-14.6Z"
|
||||
/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" {{ $attributes }}>
|
||||
{{-- Document outline with folded corner --}}
|
||||
<path d="M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Z" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M14 2v5a1 1 0 0 0 1 1h5" stroke="currentColor" stroke-width="1.5" />
|
||||
{{-- Upload arrow --}}
|
||||
<path d="M12 17v-6m0 0-2.5 2.5M12 11l2.5 2.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 714 B After Width: | Height: | Size: 517 B |
@@ -1,17 +1,4 @@
|
||||
@props([
|
||||
'sidebar' => false,
|
||||
])
|
||||
|
||||
@if($sidebar)
|
||||
<flux:sidebar.brand name="Laravel Starter Kit" {{ $attributes }}>
|
||||
<x-slot name="logo" class="flex aspect-square size-8 items-center justify-center rounded-md bg-accent-content text-accent-foreground">
|
||||
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
|
||||
</x-slot>
|
||||
</flux:sidebar.brand>
|
||||
@else
|
||||
<flux:brand name="Laravel Starter Kit" {{ $attributes }}>
|
||||
<x-slot name="logo" class="flex aspect-square size-8 items-center justify-center rounded-md bg-accent-content text-accent-foreground">
|
||||
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
|
||||
</x-slot>
|
||||
</flux:brand>
|
||||
@endif
|
||||
<a href="{{ route('home') }}" {{ $attributes->merge(['class' => 'flex items-center gap-2 font-semibold']) }} wire:navigate>
|
||||
<x-app-logo-icon class="size-6 fill-current" />
|
||||
<span>{{ \App\Models\Setting::get('site_title') ?: config('app.name', 'SealShare') }}</span>
|
||||
</a>
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
])
|
||||
|
||||
<div class="flex w-full flex-col text-center">
|
||||
<flux:heading size="xl">{{ $title }}</flux:heading>
|
||||
<flux:subheading>{{ $description }}</flux:subheading>
|
||||
<h2 class="text-xl font-bold">{{ $title }}</h2>
|
||||
<p class="text-sm opacity-60 mt-1">{{ $description }}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,39 +1 @@
|
||||
<flux:dropdown position="bottom" align="start">
|
||||
<flux:sidebar.profile
|
||||
:name="auth()->user()->name"
|
||||
:initials="auth()->user()->initials()"
|
||||
icon:trailing="chevrons-up-down"
|
||||
data-test="sidebar-menu-button"
|
||||
/>
|
||||
|
||||
<flux:menu>
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
|
||||
<flux:avatar
|
||||
:name="auth()->user()->name"
|
||||
:initials="auth()->user()->initials()"
|
||||
/>
|
||||
<div class="grid flex-1 text-start text-sm leading-tight">
|
||||
<flux:heading class="truncate">{{ auth()->user()->name }}</flux:heading>
|
||||
<flux:text class="truncate">{{ auth()->user()->email }}</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
<flux:menu.separator />
|
||||
<flux:menu.radio.group>
|
||||
<flux:menu.item :href="route('profile.edit')" icon="cog" wire:navigate>
|
||||
{{ __('Settings') }}
|
||||
</flux:menu.item>
|
||||
<form method="POST" action="{{ route('logout') }}" class="w-full">
|
||||
@csrf
|
||||
<flux:menu.item
|
||||
as="button"
|
||||
type="submit"
|
||||
icon="arrow-right-start-on-rectangle"
|
||||
class="w-full cursor-pointer"
|
||||
data-test="logout-button"
|
||||
>
|
||||
{{ __('Log Out') }}
|
||||
</flux:menu.item>
|
||||
</form>
|
||||
</flux:menu.radio.group>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
{{-- Desktop user menu - integrated into sidebar layout --}}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<x-layouts::app :title="__('Dashboard')">
|
||||
<div class="flex h-full w-full flex-1 flex-col gap-4 rounded-xl">
|
||||
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<div class="relative aspect-video overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
|
||||
<x-placeholder-pattern class="absolute inset-0 size-full stroke-gray-900/20 dark:stroke-neutral-100/20" />
|
||||
<div class="relative aspect-video overflow-hidden rounded-xl border border-base-300">
|
||||
<x-placeholder-pattern class="absolute inset-0 size-full stroke-current/20" />
|
||||
</div>
|
||||
<div class="relative aspect-video overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
|
||||
<x-placeholder-pattern class="absolute inset-0 size-full stroke-gray-900/20 dark:stroke-neutral-100/20" />
|
||||
<div class="relative aspect-video overflow-hidden rounded-xl border border-base-300">
|
||||
<x-placeholder-pattern class="absolute inset-0 size-full stroke-current/20" />
|
||||
</div>
|
||||
<div class="relative aspect-video overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
|
||||
<x-placeholder-pattern class="absolute inset-0 size-full stroke-gray-900/20 dark:stroke-neutral-100/20" />
|
||||
<div class="relative aspect-video overflow-hidden rounded-xl border border-base-300">
|
||||
<x-placeholder-pattern class="absolute inset-0 size-full stroke-current/20" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative h-full flex-1 overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
|
||||
<x-placeholder-pattern class="absolute inset-0 size-full stroke-gray-900/20 dark:stroke-neutral-100/20" />
|
||||
<div class="relative h-full flex-1 overflow-hidden rounded-xl border border-base-300">
|
||||
<x-placeholder-pattern class="absolute inset-0 size-full stroke-current/20" />
|
||||
</div>
|
||||
</div>
|
||||
</x-layouts::app>
|
||||
|
||||
@@ -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
|
||||
|
||||
<svg
|
||||
{{ $attributes->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"
|
||||
>
|
||||
<path d="M12 7v14" />
|
||||
<path d="M16 12h2" />
|
||||
<path d="M16 8h2" />
|
||||
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
|
||||
<path d="M6 12h2" />
|
||||
<path d="M6 8h2" />
|
||||
</svg>
|
||||
@@ -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
|
||||
|
||||
<svg
|
||||
{{ $attributes->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"
|
||||
>
|
||||
<path d="m7 15 5 5 5-5" />
|
||||
<path d="m7 9 5-5 5 5" />
|
||||
</svg>
|
||||
@@ -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
|
||||
|
||||
<svg
|
||||
{{ $attributes->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"
|
||||
>
|
||||
<path d="M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v5" />
|
||||
<circle cx="13" cy="12" r="2" />
|
||||
<path d="M18 19c-2.8 0-5-2.2-5-5v8" />
|
||||
<circle cx="20" cy="19" r="2" />
|
||||
</svg>
|
||||
@@ -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
|
||||
|
||||
<svg
|
||||
{{ $attributes->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"
|
||||
>
|
||||
<rect width="7" height="7" x="3" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="14" rx="1" />
|
||||
<rect width="7" height="7" x="3" y="14" rx="1" />
|
||||
</svg>
|
||||
@@ -1,51 +0,0 @@
|
||||
@props([
|
||||
'expandable' => false,
|
||||
'expanded' => true,
|
||||
'heading' => null,
|
||||
])
|
||||
|
||||
<?php if ($expandable && $heading): ?>
|
||||
|
||||
<ui-disclosure
|
||||
{{ $attributes->class('group/disclosure') }}
|
||||
@if ($expanded === true) open @endif
|
||||
data-flux-navlist-group
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="group/disclosure-button mb-[2px] flex h-10 w-full items-center rounded-lg text-zinc-500 hover:bg-zinc-800/5 hover:text-zinc-800 lg:h-8 dark:text-white/80 dark:hover:bg-white/[7%] dark:hover:text-white"
|
||||
>
|
||||
<div class="ps-3 pe-4">
|
||||
<flux:icon.chevron-down class="hidden size-3! group-data-open/disclosure-button:block" />
|
||||
<flux:icon.chevron-right class="block size-3! group-data-open/disclosure-button:hidden" />
|
||||
</div>
|
||||
|
||||
<span class="text-sm font-medium leading-none">{{ $heading }}</span>
|
||||
</button>
|
||||
|
||||
<div class="relative hidden space-y-[2px] ps-7 data-open:block" @if ($expanded === true) data-open @endif>
|
||||
<div class="absolute inset-y-[3px] start-0 ms-4 w-px bg-zinc-200 dark:bg-white/30"></div>
|
||||
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</ui-disclosure>
|
||||
|
||||
<?php elseif ($heading): ?>
|
||||
|
||||
<div {{ $attributes->class('block space-y-[2px]') }}>
|
||||
<div class="px-1 py-2">
|
||||
<div class="text-xs leading-none text-zinc-400">{{ $heading }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php else: ?>
|
||||
|
||||
<div {{ $attributes->class('block space-y-[2px]') }}>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
|
||||
<?php endif; ?>
|
||||
@@ -1,5 +1,3 @@
|
||||
<x-layouts::app.sidebar :title="$title ?? null">
|
||||
<flux:main>
|
||||
{{ $slot }}
|
||||
</flux:main>
|
||||
{{ $slot }}
|
||||
</x-layouts::app.sidebar>
|
||||
|
||||
@@ -1,78 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
||||
<head>
|
||||
@include('partials.head')
|
||||
</head>
|
||||
<body class="min-h-screen bg-white dark:bg-zinc-800">
|
||||
<flux:header container class="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<flux:sidebar.toggle class="lg:hidden mr-2" icon="bars-2" inset="left" />
|
||||
|
||||
<x-app-logo href="{{ route('dashboard') }}" wire:navigate />
|
||||
|
||||
<flux:navbar class="-mb-px max-lg:hidden">
|
||||
<flux:navbar.item icon="layout-grid" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>
|
||||
{{ __('Dashboard') }}
|
||||
</flux:navbar.item>
|
||||
</flux:navbar>
|
||||
|
||||
<flux:spacer />
|
||||
|
||||
<flux:navbar class="me-1.5 space-x-0.5 rtl:space-x-reverse py-0!">
|
||||
<flux:tooltip :content="__('Search')" position="bottom">
|
||||
<flux:navbar.item class="!h-10 [&>div>svg]:size-5" icon="magnifying-glass" href="#" :label="__('Search')" />
|
||||
</flux:tooltip>
|
||||
<flux:tooltip :content="__('Repository')" position="bottom">
|
||||
<flux:navbar.item
|
||||
class="h-10 max-lg:hidden [&>div>svg]:size-5"
|
||||
icon="folder-git-2"
|
||||
href="https://github.com/laravel/livewire-starter-kit"
|
||||
target="_blank"
|
||||
:label="__('Repository')"
|
||||
/>
|
||||
</flux:tooltip>
|
||||
<flux:tooltip :content="__('Documentation')" position="bottom">
|
||||
<flux:navbar.item
|
||||
class="h-10 max-lg:hidden [&>div>svg]:size-5"
|
||||
icon="book-open-text"
|
||||
href="https://laravel.com/docs/starter-kits#livewire"
|
||||
target="_blank"
|
||||
:label="__('Documentation')"
|
||||
/>
|
||||
</flux:tooltip>
|
||||
</flux:navbar>
|
||||
|
||||
<x-desktop-user-menu />
|
||||
</flux:header>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
<flux:sidebar collapsible="mobile" sticky class="lg:hidden border-e border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<flux:sidebar.header>
|
||||
<x-app-logo :sidebar="true" href="{{ route('dashboard') }}" wire:navigate />
|
||||
<flux:sidebar.collapse class="in-data-flux-sidebar-on-desktop:not-in-data-flux-sidebar-collapsed-desktop:-mr-2" />
|
||||
</flux:sidebar.header>
|
||||
|
||||
<flux:sidebar.nav>
|
||||
<flux:sidebar.group :heading="__('Platform')">
|
||||
<flux:sidebar.item icon="layout-grid" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>
|
||||
{{ __('Dashboard') }}
|
||||
</flux:sidebar.item>
|
||||
</flux:sidebar.group>
|
||||
</flux:sidebar.nav>
|
||||
|
||||
<flux:spacer />
|
||||
|
||||
<flux:sidebar.nav>
|
||||
<flux:sidebar.item icon="folder-git-2" href="https://github.com/laravel/livewire-starter-kit" target="_blank">
|
||||
{{ __('Repository') }}
|
||||
</flux:sidebar.item>
|
||||
<flux:sidebar.item icon="book-open-text" href="https://laravel.com/docs/starter-kits#livewire" target="_blank">
|
||||
{{ __('Documentation') }}
|
||||
</flux:sidebar.item>
|
||||
</flux:sidebar.nav>
|
||||
</flux:sidebar>
|
||||
|
||||
{{ $slot }}
|
||||
|
||||
@fluxScripts
|
||||
</body>
|
||||
</html>
|
||||
{{-- Header layout not used - redirects to sidebar layout --}}
|
||||
<x-layouts::app.sidebar :title="$title ?? null">
|
||||
{{ $slot }}
|
||||
</x-layouts::app.sidebar>
|
||||
|
||||
@@ -1,96 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" data-theme="dark">
|
||||
<script>document.documentElement.setAttribute('data-theme', localStorage.getItem('mary-theme')?.replaceAll('"','') || 'dark')</script>
|
||||
<head>
|
||||
@include('partials.head')
|
||||
</head>
|
||||
<body class="min-h-screen bg-white dark:bg-zinc-800">
|
||||
<flux:sidebar sticky collapsible="mobile" class="border-e border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<flux:sidebar.header>
|
||||
<x-app-logo :sidebar="true" href="{{ route('dashboard') }}" wire:navigate />
|
||||
<flux:sidebar.collapse class="lg:hidden" />
|
||||
</flux:sidebar.header>
|
||||
<body class="min-h-screen font-sans antialiased bg-base-200/50 flex flex-col">
|
||||
|
||||
<flux:sidebar.nav>
|
||||
<flux:sidebar.group :heading="__('Platform')" class="grid">
|
||||
<flux:sidebar.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>
|
||||
{{ __('Dashboard') }}
|
||||
</flux:sidebar.item>
|
||||
</flux:sidebar.group>
|
||||
</flux:sidebar.nav>
|
||||
{{-- MAIN CONTENT --}}
|
||||
<main class="flex-1 w-full max-w-5xl mx-auto px-4 py-8">
|
||||
{{ $slot }}
|
||||
</main>
|
||||
|
||||
<flux:spacer />
|
||||
{{-- FOOTER NAV --}}
|
||||
<footer class="border-t border-base-300 bg-base-100/50">
|
||||
<div class="max-w-5xl mx-auto px-4 py-3 flex items-center justify-between text-sm">
|
||||
<a href="{{ route('upload') }}" class="font-medium opacity-70 hover:opacity-100 transition-opacity">
|
||||
{{ \App\Models\Setting::get('site_title') ?: config('app.name', 'SealShare') }}
|
||||
</a>
|
||||
<nav class="flex items-center gap-4">
|
||||
<x-theme-toggle class="opacity-60 hover:opacity-100 transition-opacity" />
|
||||
@auth
|
||||
@if(auth()->user()->is_admin)
|
||||
<a href="{{ route('admin.dashboard') }}" class="opacity-60 hover:opacity-100 transition-opacity">{{ __('Dashboard') }}</a>
|
||||
<a href="{{ route('admin.settings') }}" class="opacity-60 hover:opacity-100 transition-opacity">{{ __('Settings') }}</a>
|
||||
@endif
|
||||
<a href="{{ route('profile.edit') }}" class="opacity-60 hover:opacity-100 transition-opacity">{{ __('Profile') }}</a>
|
||||
<form method="POST" action="{{ route('logout') }}" class="inline">
|
||||
@csrf
|
||||
<button type="submit" class="opacity-60 hover:opacity-100 transition-opacity">{{ __('Logout') }}</button>
|
||||
</form>
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="opacity-60 hover:opacity-100 transition-opacity">{{ __('Login') }}</a>
|
||||
@endauth
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<flux:sidebar.nav>
|
||||
<flux:sidebar.item icon="folder-git-2" href="https://github.com/laravel/livewire-starter-kit" target="_blank">
|
||||
{{ __('Repository') }}
|
||||
</flux:sidebar.item>
|
||||
|
||||
<flux:sidebar.item icon="book-open-text" href="https://laravel.com/docs/starter-kits#livewire" target="_blank">
|
||||
{{ __('Documentation') }}
|
||||
</flux:sidebar.item>
|
||||
</flux:sidebar.nav>
|
||||
|
||||
<x-desktop-user-menu class="hidden lg:block" :name="auth()->user()->name" />
|
||||
</flux:sidebar>
|
||||
|
||||
|
||||
<!-- Mobile User Menu -->
|
||||
<flux:header class="lg:hidden">
|
||||
<flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />
|
||||
|
||||
<flux:spacer />
|
||||
|
||||
<flux:dropdown position="top" align="end">
|
||||
<flux:profile
|
||||
:initials="auth()->user()->initials()"
|
||||
icon-trailing="chevron-down"
|
||||
/>
|
||||
|
||||
<flux:menu>
|
||||
<flux:menu.radio.group>
|
||||
<div class="p-0 text-sm font-normal">
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
|
||||
<flux:avatar
|
||||
:name="auth()->user()->name"
|
||||
:initials="auth()->user()->initials()"
|
||||
/>
|
||||
|
||||
<div class="grid flex-1 text-start text-sm leading-tight">
|
||||
<flux:heading class="truncate">{{ auth()->user()->name }}</flux:heading>
|
||||
<flux:text class="truncate">{{ auth()->user()->email }}</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</flux:menu.radio.group>
|
||||
|
||||
<flux:menu.separator />
|
||||
|
||||
<flux:menu.radio.group>
|
||||
<flux:menu.item :href="route('profile.edit')" icon="cog" wire:navigate>
|
||||
{{ __('Settings') }}
|
||||
</flux:menu.item>
|
||||
</flux:menu.radio.group>
|
||||
|
||||
<flux:menu.separator />
|
||||
|
||||
<form method="POST" action="{{ route('logout') }}" class="w-full">
|
||||
@csrf
|
||||
<flux:menu.item
|
||||
as="button"
|
||||
type="submit"
|
||||
icon="arrow-right-start-on-rectangle"
|
||||
class="w-full cursor-pointer"
|
||||
data-test="logout-button"
|
||||
>
|
||||
{{ __('Log Out') }}
|
||||
</flux:menu.item>
|
||||
</form>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
</flux:header>
|
||||
|
||||
{{ $slot }}
|
||||
|
||||
@fluxScripts
|
||||
{{-- Toast --}}
|
||||
<x-toast />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,26 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
||||
<head>
|
||||
@include('partials.head')
|
||||
</head>
|
||||
<body class="min-h-screen bg-neutral-100 antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
|
||||
<div class="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||
<div class="flex w-full max-w-md flex-col gap-6">
|
||||
<a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium" wire:navigate>
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-md">
|
||||
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
|
||||
</span>
|
||||
|
||||
<span class="sr-only">{{ config('app.name', 'Laravel') }}</span>
|
||||
</a>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
|
||||
<div class="px-10 py-8">{{ $slot }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@fluxScripts
|
||||
</body>
|
||||
</html>
|
||||
{{-- Card auth layout - delegates to simple layout --}}
|
||||
<x-layouts::auth.simple :title="$title ?? null">
|
||||
{{ $slot }}
|
||||
</x-layouts::auth.simple>
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" data-theme="dark">
|
||||
<script>document.documentElement.setAttribute('data-theme', localStorage.getItem('mary-theme')?.replaceAll('"','') || 'dark')</script>
|
||||
<head>
|
||||
@include('partials.head')
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="min-h-screen bg-white antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
|
||||
<div class="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||
<body class="min-h-screen bg-base-200 antialiased">
|
||||
<div class="flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||
<div class="flex w-full max-w-sm flex-col gap-2">
|
||||
<a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium" wire:navigate>
|
||||
<span class="flex h-9 w-9 mb-1 items-center justify-center rounded-md">
|
||||
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
|
||||
<x-app-logo-icon class="size-9 fill-current" />
|
||||
</span>
|
||||
<span class="sr-only">{{ config('app.name', 'Laravel') }}</span>
|
||||
<span class="sr-only">{{ \App\Models\Setting::get('site_title') ?: config('app.name', 'SealShare') }}</span>
|
||||
</a>
|
||||
<div class="flex flex-col gap-6">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<x-theme-toggle class="opacity-60 hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@fluxScripts
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,43 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
||||
<head>
|
||||
@include('partials.head')
|
||||
</head>
|
||||
<body class="min-h-screen bg-white antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
|
||||
<div class="relative grid h-dvh flex-col items-center justify-center px-8 sm:px-0 lg:max-w-none lg:grid-cols-2 lg:px-0">
|
||||
<div class="bg-muted relative hidden h-full flex-col p-10 text-white lg:flex dark:border-e dark:border-neutral-800">
|
||||
<div class="absolute inset-0 bg-neutral-900"></div>
|
||||
<a href="{{ route('home') }}" class="relative z-20 flex items-center text-lg font-medium" wire:navigate>
|
||||
<span class="flex h-10 w-10 items-center justify-center rounded-md">
|
||||
<x-app-logo-icon class="me-2 h-7 fill-current text-white" />
|
||||
</span>
|
||||
{{ config('app.name', 'Laravel') }}
|
||||
</a>
|
||||
|
||||
@php
|
||||
[$message, $author] = str(Illuminate\Foundation\Inspiring::quotes()->random())->explode('-');
|
||||
@endphp
|
||||
|
||||
<div class="relative z-20 mt-auto">
|
||||
<blockquote class="space-y-2">
|
||||
<flux:heading size="lg">“{{ trim($message) }}”</flux:heading>
|
||||
<footer><flux:heading>{{ trim($author) }}</flux:heading></footer>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:p-8">
|
||||
<div class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
|
||||
<a href="{{ route('home') }}" class="z-20 flex flex-col items-center gap-2 font-medium lg:hidden" wire:navigate>
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-md">
|
||||
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
|
||||
</span>
|
||||
|
||||
<span class="sr-only">{{ config('app.name', 'Laravel') }}</span>
|
||||
</a>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@fluxScripts
|
||||
</body>
|
||||
</html>
|
||||
{{-- Split auth layout - delegates to simple layout --}}
|
||||
<x-layouts::auth.simple :title="$title ?? null">
|
||||
{{ $slot }}
|
||||
</x-layouts::auth.simple>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold mb-6">{{ __('Admin Dashboard') }}</h1>
|
||||
|
||||
{{-- Stats --}}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<p class="text-sm opacity-60">{{ __('Total Shares') }}</p>
|
||||
<p class="text-2xl font-bold">{{ $totalShares }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<p class="text-sm opacity-60">{{ __('Active Shares') }}</p>
|
||||
<p class="text-2xl font-bold">{{ $activeShares }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<p class="text-sm opacity-60">{{ __('Total Files') }}</p>
|
||||
<p class="text-2xl font-bold">{{ $totalFiles }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<p class="text-sm opacity-60">{{ __('Disk Usage') }}</p>
|
||||
<p class="text-2xl font-bold">{{ Number::fileSize($usedSpace) }}</p>
|
||||
<progress class="progress progress-primary w-full mt-1" value="{{ $maxQuota > 0 ? ($usedSpace / $maxQuota) * 100 : 0 }}" max="100"></progress>
|
||||
<p class="text-xs opacity-50">{{ Number::fileSize($usedSpace) }} / {{ Number::fileSize($maxQuota) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Shares Table --}}
|
||||
<x-card title="{{ __('All Shares') }}" shadow>
|
||||
<x-table :headers="$headers" :rows="$shares" :sort-by="$sortBy" with-pagination>
|
||||
@scope('cell_total_size', $share)
|
||||
{{ Number::fileSize($share->total_size) }}
|
||||
@endscope
|
||||
|
||||
@scope('cell_expires_at', $share)
|
||||
@if ($share->expires_at)
|
||||
<span class="{{ $share->isExpired() ? 'text-error' : '' }}">
|
||||
{{ $share->expires_at->diffForHumans() }}
|
||||
</span>
|
||||
@else
|
||||
<span class="opacity-50">{{ __('Never') }}</span>
|
||||
@endif
|
||||
@endscope
|
||||
|
||||
@scope('cell_created_at', $share)
|
||||
{{ $share->created_at->diffForHumans() }}
|
||||
@endscope
|
||||
|
||||
@scope('actions', $share)
|
||||
<div class="flex gap-1">
|
||||
<a href="{{ route('share.download', $share) }}" class="btn btn-ghost btn-xs" target="_blank">
|
||||
<x-icon name="o-eye" class="w-4 h-4" />
|
||||
</a>
|
||||
<x-button
|
||||
icon="o-trash"
|
||||
class="btn-ghost btn-xs text-error"
|
||||
wire:click="deleteShare({{ $share->id }})"
|
||||
wire:confirm="{{ __('Are you sure you want to delete this share?') }}"
|
||||
/>
|
||||
</div>
|
||||
@endscope
|
||||
</x-table>
|
||||
</x-card>
|
||||
</div>
|
||||
@@ -0,0 +1,151 @@
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-6">{{ __('System Settings') }}</h1>
|
||||
|
||||
@if (session('message'))
|
||||
<div class="alert alert-success mb-6">
|
||||
<x-icon name="o-check-circle" class="w-5 h-5" />
|
||||
<span>{{ session('message') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form wire:submit="saveSettings">
|
||||
<x-card title="{{ __('Branding') }}" shadow class="mb-6">
|
||||
<div class="space-y-4">
|
||||
<x-input
|
||||
wire:model="siteTitle"
|
||||
label="{{ __('Site Title') }}"
|
||||
hint="{{ __('Displayed as the heading on the upload page.') }}"
|
||||
/>
|
||||
|
||||
<x-textarea
|
||||
wire:model="siteDescription"
|
||||
label="{{ __('Site Description') }}"
|
||||
hint="{{ __('Displayed below the title on the upload page.') }}"
|
||||
rows="3"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label class="label label-text font-semibold">{{ __('Logo') }}</label>
|
||||
|
||||
@if ($currentLogo)
|
||||
<div class="flex items-center gap-4 mb-3">
|
||||
<img src="{{ Storage::disk('public')->url($currentLogo) }}" alt="{{ __('Site Logo') }}" class="h-16 w-auto rounded" />
|
||||
<x-button
|
||||
label="{{ __('Remove Logo') }}"
|
||||
class="btn-sm btn-ghost text-error"
|
||||
wire:click="removeLogo"
|
||||
wire:confirm="{{ __('Remove the logo?') }}"
|
||||
/>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<input type="file" wire:model="siteLogo" accept="image/*,.svg,.svgz" class="file-input file-input-bordered w-full" />
|
||||
|
||||
@if ($siteLogo && is_object($siteLogo))
|
||||
<div class="mt-2">
|
||||
@if (str_contains($siteLogo->getMimeType(), 'svg'))
|
||||
<p class="text-sm opacity-60">{{ __('SVG selected: :name', ['name' => $siteLogo->getClientOriginalName()]) }}</p>
|
||||
@else
|
||||
<p class="text-sm opacity-60">{{ __('Preview:') }}</p>
|
||||
<img src="{{ $siteLogo->temporaryUrl() }}" alt="{{ __('Logo preview') }}" class="h-16 w-auto rounded mt-1" />
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@error('siteLogo')
|
||||
<p class="text-error text-sm mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
|
||||
<p class="text-xs opacity-50 mt-1">{{ __('Max 2MB. Recommended: PNG or SVG.') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</x-card>
|
||||
|
||||
<x-card title="{{ __('Upload Protection') }}" shadow class="mb-6">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<x-password
|
||||
wire:model="systemPassword"
|
||||
label="{{ __('System Upload Password') }}"
|
||||
hint="{{ __('Leave blank to keep current. Set a password to require it before uploading.') }}"
|
||||
/>
|
||||
|
||||
@if ($hasSystemPassword)
|
||||
<div class="mt-2">
|
||||
<x-button
|
||||
label="{{ __('Clear System Password') }}"
|
||||
class="btn-sm btn-ghost text-error"
|
||||
wire:click="clearSystemPassword"
|
||||
wire:confirm="{{ __('Remove the system password?') }}"
|
||||
/>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-card>
|
||||
|
||||
<x-card title="{{ __('Upload Limits') }}" shadow class="mb-6">
|
||||
<div class="space-y-4">
|
||||
<x-toggle
|
||||
wire:model.live="allowNeverExpire"
|
||||
label="{{ __('Allow shares to never expire') }}"
|
||||
hint="{{ __('When disabled, users must select an expiration time.') }}"
|
||||
/>
|
||||
|
||||
<x-select
|
||||
wire:model="defaultExpiration"
|
||||
label="{{ __('Default Expiration') }}"
|
||||
:placeholder="$allowNeverExpire ? __('None') : null"
|
||||
:options="[
|
||||
['id' => '1h', 'name' => __('1 Hour')],
|
||||
['id' => '24h', 'name' => __('24 Hours')],
|
||||
['id' => '48h', 'name' => __('48 Hours')],
|
||||
['id' => '7d', 'name' => __('7 Days')],
|
||||
['id' => '14d', 'name' => __('14 Days')],
|
||||
['id' => '30d', 'name' => __('30 Days')],
|
||||
]"
|
||||
/>
|
||||
|
||||
<x-input
|
||||
wire:model="maxFileSize"
|
||||
label="{{ __('Max file size (MB)') }}"
|
||||
type="number"
|
||||
min="1"
|
||||
max="{{ $phpMaxUploadMb }}"
|
||||
suffix="MB"
|
||||
hint="{{ __('PHP limit: :max MB (upload_max_filesize / post_max_size)', ['max' => $phpMaxUploadMb]) }}"
|
||||
/>
|
||||
|
||||
<x-input
|
||||
wire:model="maxFilesPerShare"
|
||||
label="{{ __('Max files per share') }}"
|
||||
type="number"
|
||||
min="1"
|
||||
/>
|
||||
|
||||
<x-input
|
||||
wire:model="maxSizePerShare"
|
||||
label="{{ __('Max total size per share (GB)') }}"
|
||||
type="number"
|
||||
min="1"
|
||||
suffix="GB"
|
||||
/>
|
||||
</div>
|
||||
</x-card>
|
||||
|
||||
<x-card title="{{ __('Storage') }}" shadow class="mb-6">
|
||||
<div class="space-y-4">
|
||||
<x-input
|
||||
wire:model="maxStorageQuota"
|
||||
label="{{ __('Max storage quota (GB)') }}"
|
||||
type="number"
|
||||
min="1"
|
||||
suffix="GB"
|
||||
hint="{{ __('When reached, new uploads are blocked.') }}"
|
||||
/>
|
||||
</div>
|
||||
</x-card>
|
||||
|
||||
<x-button type="submit" label="{{ __('Save Settings') }}" class="btn-primary w-full" icon="o-check" spinner="saveSettings" />
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,193 @@
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="text-center mb-6">
|
||||
@if ($siteLogo)
|
||||
<img src="{{ Storage::disk('public')->url($siteLogo) }}" alt="{{ $siteTitle ?: config('app.name', 'SealShare') }}" class="h-20 w-auto mx-auto mb-4" />
|
||||
@else
|
||||
<x-app-logo-icon class="h-16 w-16 mx-auto mb-4" />
|
||||
@endif
|
||||
|
||||
<h1 class="text-2xl font-bold">{{ $siteTitle ?: config('app.name', 'SealShare') }}</h1>
|
||||
|
||||
<p class="mt-2 opacity-70">{{ $siteDescription ?: __('Share your files safely and securely') }}</p>
|
||||
</div>
|
||||
|
||||
@if ($isStorageFull)
|
||||
<div class="alert alert-warning mb-6">
|
||||
<x-icon name="o-exclamation-triangle" class="w-5 h-5" />
|
||||
<span>{{ __('Storage is full. Uploads are temporarily disabled.') }}</span>
|
||||
</div>
|
||||
@else
|
||||
<form
|
||||
wire:submit="createShare"
|
||||
x-data="{
|
||||
uploading: false,
|
||||
progress: 0,
|
||||
dragging: false,
|
||||
handleDrop(e) {
|
||||
this.dragging = false;
|
||||
const items = e.dataTransfer.items;
|
||||
const files = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const entry = items[i].webkitGetAsEntry?.();
|
||||
if (entry) {
|
||||
this.traverseEntry(entry, '', files);
|
||||
} else if (items[i].kind === 'file') {
|
||||
files.push({ file: items[i].getAsFile(), path: null });
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const dt = new DataTransfer();
|
||||
const paths = [];
|
||||
files.forEach(f => {
|
||||
dt.items.add(f.file);
|
||||
paths.push(f.path);
|
||||
});
|
||||
$wire.relativePaths = paths;
|
||||
$wire.uploadMultiple('files', dt.files);
|
||||
}, 500);
|
||||
},
|
||||
traverseEntry(entry, path, files) {
|
||||
if (entry.isFile) {
|
||||
entry.file(file => {
|
||||
files.push({ file, path: path ? path + '/' + file.name : null });
|
||||
});
|
||||
} else if (entry.isDirectory) {
|
||||
const reader = entry.createReader();
|
||||
reader.readEntries(entries => {
|
||||
entries.forEach(e => this.traverseEntry(e, path ? path + '/' + entry.name : entry.name, files));
|
||||
});
|
||||
}
|
||||
}
|
||||
}"
|
||||
x-on:livewire-upload-start="uploading = true; progress = 0"
|
||||
x-on:livewire-upload-finish="progress = 100"
|
||||
x-on:livewire-upload-cancel="uploading = false"
|
||||
x-on:livewire-upload-error="uploading = false"
|
||||
x-on:livewire-upload-progress="progress = $event.detail.progress"
|
||||
>
|
||||
{{-- Drop Zone --}}
|
||||
<div
|
||||
class="border-2 border-dashed rounded-xl p-8 text-center transition-colors mb-6"
|
||||
:class="{
|
||||
'border-primary bg-primary/5': dragging,
|
||||
'border-base-300 hover:border-primary/50': !dragging,
|
||||
'opacity-50 pointer-events-none': uploading
|
||||
}"
|
||||
@dragover.prevent="dragging = true"
|
||||
@dragleave.prevent="dragging = false"
|
||||
@drop.prevent="handleDrop($event)"
|
||||
>
|
||||
<x-icon name="o-cloud-arrow-up" class="w-12 h-12 mx-auto opacity-40 mb-3" />
|
||||
<p class="font-medium">{{ __('Drag & drop files or folders here') }}</p>
|
||||
<p class="text-sm opacity-60 mt-1">{{ __('or click to browse') }}</p>
|
||||
|
||||
<label class="btn btn-outline btn-sm mt-4 cursor-pointer" :class="uploading && 'btn-disabled'">
|
||||
{{ __('Browse Files') }}
|
||||
<input
|
||||
type="file"
|
||||
wire:model="files"
|
||||
multiple
|
||||
class="hidden"
|
||||
:disabled="uploading"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{{-- Upload Progress --}}
|
||||
<div x-show="uploading" x-cloak class="mb-6">
|
||||
<template x-if="progress < 100">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium">{{ __('Uploading...') }} <span x-text="Math.round(progress)"></span>%</span>
|
||||
<button type="button" class="btn btn-ghost btn-xs" x-on:click="$wire.cancelUpload('files')">
|
||||
{{ __('Cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
<progress class="progress progress-primary w-full" max="100" x-bind:value="progress"></progress>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="progress >= 100">
|
||||
<div class="flex items-center gap-3 text-sm font-medium">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{{ __('Processing files...') }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{{-- Errors --}}
|
||||
@error('files')
|
||||
<div x-init="uploading = false" class="alert alert-error mb-4">{{ $message }}</div>
|
||||
@enderror
|
||||
|
||||
{{-- File List --}}
|
||||
@if (count($files))
|
||||
<div x-init="uploading = false" class="mb-6">
|
||||
<h3 class="font-semibold mb-2">{{ __('Selected Files') }} ({{ count($files) }})</h3>
|
||||
<div class="space-y-1 max-h-60 overflow-y-auto">
|
||||
@foreach ($files as $index => $file)
|
||||
<div class="flex items-center justify-between px-3 py-2 rounded-lg bg-base-200 text-sm">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<x-icon name="o-document" class="w-4 h-4 flex-shrink-0" />
|
||||
<span class="truncate">
|
||||
{{ $relativePaths[$index] ?? $file->getClientOriginalName() }}
|
||||
</span>
|
||||
<span class="opacity-50 flex-shrink-0">
|
||||
({{ Number::fileSize($file->getSize()) }})
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" wire:click="removeFile({{ $index }})" class="btn btn-ghost btn-xs">
|
||||
<x-icon name="o-x-mark" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Options --}}
|
||||
<x-card title="{{ __('Share Options') }}" class="mb-6" shadow>
|
||||
<div class="space-y-4">
|
||||
<x-toggle wire:model.live="usePassword" label="{{ __('Password protect') }}" />
|
||||
|
||||
@if ($usePassword)
|
||||
<x-password wire:model="password" label="{{ __('Password') }}" />
|
||||
@endif
|
||||
|
||||
<x-select
|
||||
wire:model="expiration"
|
||||
label="{{ __('Expiration') }}"
|
||||
:placeholder="$allowNeverExpire ? __('Never') : null"
|
||||
:options="[
|
||||
['id' => '1h', 'name' => __('1 Hour')],
|
||||
['id' => '24h', 'name' => __('24 Hours')],
|
||||
['id' => '48h', 'name' => __('48 Hours')],
|
||||
['id' => '7d', 'name' => __('7 Days')],
|
||||
['id' => '14d', 'name' => __('14 Days')],
|
||||
['id' => '30d', 'name' => __('30 Days')],
|
||||
]"
|
||||
/>
|
||||
|
||||
<x-input
|
||||
wire:model="maxDownloads"
|
||||
label="{{ __('Max downloads') }}"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="{{ __('Unlimited') }}"
|
||||
/>
|
||||
</div>
|
||||
</x-card>
|
||||
|
||||
{{-- Submit --}}
|
||||
<x-button
|
||||
type="submit"
|
||||
label="{{ __('Create Share Link') }}"
|
||||
class="btn-primary w-full"
|
||||
icon="o-link"
|
||||
spinner="createShare"
|
||||
x-bind:disabled="uploading || {{ count($files) === 0 ? 'true' : 'false' }}"
|
||||
/>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
@@ -0,0 +1,40 @@
|
||||
<div class="flex flex-col gap-6">
|
||||
<x-auth-header :title="__('Setup SealShare')" :description="__('Create your admin account to get started')" />
|
||||
|
||||
<form wire:submit="createAdmin" class="flex flex-col gap-6">
|
||||
<x-input
|
||||
wire:model="name"
|
||||
label="{{ __('Name') }}"
|
||||
type="text"
|
||||
required
|
||||
autofocus
|
||||
placeholder="{{ __('Admin name') }}"
|
||||
icon="o-user"
|
||||
/>
|
||||
|
||||
<x-input
|
||||
wire:model="email"
|
||||
label="{{ __('Email address') }}"
|
||||
type="email"
|
||||
required
|
||||
placeholder="admin@example.com"
|
||||
icon="o-envelope"
|
||||
/>
|
||||
|
||||
<x-password
|
||||
wire:model="password"
|
||||
label="{{ __('Password') }}"
|
||||
required
|
||||
placeholder="{{ __('Password') }}"
|
||||
/>
|
||||
|
||||
<x-password
|
||||
wire:model="password_confirmation"
|
||||
label="{{ __('Confirm password') }}"
|
||||
required
|
||||
placeholder="{{ __('Confirm password') }}"
|
||||
/>
|
||||
|
||||
<x-button type="submit" label="{{ __('Create Admin Account') }}" class="btn-primary w-full" spinner="createAdmin" />
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,65 @@
|
||||
<div class="max-w-lg mx-auto">
|
||||
<x-card title="{{ __('Share Created!') }}" subtitle="{{ __('Your files are ready to share') }}" shadow>
|
||||
<div class="space-y-4">
|
||||
{{-- Share URL --}}
|
||||
<div
|
||||
x-data="{
|
||||
copied: false,
|
||||
url: '{{ route('share.download', $share) }}',
|
||||
async copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.url);
|
||||
this.copied = true;
|
||||
setTimeout(() => this.copied = false, 2000);
|
||||
} catch (e) {}
|
||||
}
|
||||
}"
|
||||
>
|
||||
<label class="label font-medium text-sm">{{ __('Share Link') }}</label>
|
||||
<div class="join w-full">
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
:value="url"
|
||||
class="input input-bordered join-item w-full"
|
||||
/>
|
||||
<button type="button" @click="copy()" class="btn join-item">
|
||||
<span x-show="!copied">{{ __('Copy') }}</span>
|
||||
<span x-show="copied" class="text-success">{{ __('Copied!') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Details --}}
|
||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||
<div class="bg-base-200 rounded-lg p-3">
|
||||
<span class="opacity-60">{{ __('Files') }}</span>
|
||||
<p class="font-semibold">{{ $share->files->count() }}</p>
|
||||
</div>
|
||||
<div class="bg-base-200 rounded-lg p-3">
|
||||
<span class="opacity-60">{{ __('Total Size') }}</span>
|
||||
<p class="font-semibold">{{ Number::fileSize($share->total_size) }}</p>
|
||||
</div>
|
||||
<div class="bg-base-200 rounded-lg p-3">
|
||||
<span class="opacity-60">{{ __('Expires') }}</span>
|
||||
<p class="font-semibold">{{ $share->expires_at ? $share->expires_at->diffForHumans() : __('Never') }}</p>
|
||||
</div>
|
||||
<div class="bg-base-200 rounded-lg p-3">
|
||||
<span class="opacity-60">{{ __('Max Downloads') }}</span>
|
||||
<p class="font-semibold">{{ $share->max_downloads ?? __('Unlimited') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($share->isPasswordProtected())
|
||||
<div class="alert alert-info">
|
||||
<x-icon name="o-lock-closed" class="w-5 h-5" />
|
||||
<span>{{ __('This share is password protected') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<x-slot:actions>
|
||||
<x-button label="{{ __('Upload More') }}" link="{{ route('upload') }}" icon="o-plus" />
|
||||
</x-slot:actions>
|
||||
</x-card>
|
||||
</div>
|
||||
@@ -0,0 +1,69 @@
|
||||
<div class="w-full max-w-lg mx-auto">
|
||||
<div class="text-center mb-6">
|
||||
@if ($siteLogo)
|
||||
<img src="{{ Storage::disk('public')->url($siteLogo) }}" alt="{{ $siteTitle ?: config('app.name', 'SealShare') }}" class="h-20 w-auto mx-auto mb-4" />
|
||||
@else
|
||||
<x-app-logo-icon class="h-16 w-16 mx-auto mb-4" />
|
||||
@endif
|
||||
|
||||
<h1 class="text-2xl font-bold">{{ $siteTitle ?: config('app.name', 'SealShare') }}</h1>
|
||||
|
||||
<p class="mt-2 opacity-70">{{ $siteDescription ?: __('Share your files safely and securely') }}</p>
|
||||
</div>
|
||||
|
||||
@if (! $authenticated)
|
||||
{{-- Password form --}}
|
||||
<x-card title="{{ __('Password Required') }}" subtitle="{{ __('Enter the password to access these files') }}" shadow>
|
||||
<form wire:submit="verifyPassword" class="space-y-4">
|
||||
<x-password
|
||||
wire:model="password"
|
||||
label="{{ __('Password') }}"
|
||||
required
|
||||
placeholder="{{ __('Enter share password') }}"
|
||||
/>
|
||||
|
||||
<x-slot:actions>
|
||||
<x-button type="submit" label="{{ __('Unlock') }}" class="btn-primary" icon="o-lock-open" spinner="verifyPassword" />
|
||||
</x-slot:actions>
|
||||
</form>
|
||||
</x-card>
|
||||
@else
|
||||
{{-- File list --}}
|
||||
<x-card title="{{ __('Shared Files') }}" shadow>
|
||||
<div class="space-y-2 mb-4">
|
||||
@foreach ($share->files as $file)
|
||||
<div class="flex items-center justify-between px-3 py-2 rounded-lg bg-base-200 text-sm">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<x-icon name="o-document" class="w-4 h-4 flex-shrink-0" />
|
||||
<span class="truncate">{{ $file->relative_path ?: $file->original_name }}</span>
|
||||
<span class="opacity-50 flex-shrink-0">({{ Number::fileSize($file->file_size) }})</span>
|
||||
</div>
|
||||
<a href="{{ route('share.download.file', [$share, $file]) }}" class="btn btn-ghost btn-xs">
|
||||
<x-icon name="o-arrow-down-tray" class="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if ($share->expires_at)
|
||||
<p class="text-xs opacity-50 mb-2">
|
||||
{{ __('Expires') }}: {{ $share->expires_at->diffForHumans() }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
<x-slot:actions>
|
||||
@if ($share->files->count() > 1)
|
||||
<a href="{{ route('share.download.all', $share) }}" class="btn btn-primary">
|
||||
<x-icon name="o-arrow-down-tray" class="w-4 h-4" />
|
||||
{{ __('Download All as ZIP') }}
|
||||
</a>
|
||||
@else
|
||||
<a href="{{ route('share.download.file', [$share, $share->files->first()]) }}" class="btn btn-primary">
|
||||
<x-icon name="o-arrow-down-tray" class="w-4 h-4" />
|
||||
{{ __('Download') }}
|
||||
</a>
|
||||
@endif
|
||||
</x-slot:actions>
|
||||
</x-card>
|
||||
@endif
|
||||
</div>
|
||||
@@ -0,0 +1,14 @@
|
||||
<div class="flex flex-col gap-6">
|
||||
<x-auth-header :title="__('System Password Required')" :description="__('Enter the system password to access the upload page')" />
|
||||
|
||||
<form wire:submit="verify" class="flex flex-col gap-6">
|
||||
<x-password
|
||||
wire:model="password"
|
||||
label="{{ __('Password') }}"
|
||||
required
|
||||
placeholder="{{ __('System password') }}"
|
||||
/>
|
||||
|
||||
<x-button type="submit" label="{{ __('Continue') }}" class="btn-primary w-full" spinner="verify" />
|
||||
</form>
|
||||
</div>
|
||||
@@ -10,19 +10,15 @@
|
||||
<form method="POST" action="{{ route('password.confirm.store') }}" class="flex flex-col gap-6">
|
||||
@csrf
|
||||
|
||||
<flux:input
|
||||
<x-password
|
||||
name="password"
|
||||
:label="__('Password')"
|
||||
type="password"
|
||||
label="{{ __('Password') }}"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
:placeholder="__('Password')"
|
||||
viewable
|
||||
placeholder="{{ __('Password') }}"
|
||||
/>
|
||||
|
||||
<flux:button variant="primary" type="submit" class="w-full" data-test="confirm-password-button">
|
||||
{{ __('Confirm') }}
|
||||
</flux:button>
|
||||
<x-button type="submit" label="{{ __('Confirm') }}" class="btn-primary w-full" data-test="confirm-password-button" />
|
||||
</form>
|
||||
</div>
|
||||
</x-layouts::auth>
|
||||
|
||||
@@ -9,23 +9,22 @@
|
||||
@csrf
|
||||
|
||||
<!-- Email Address -->
|
||||
<flux:input
|
||||
<x-input
|
||||
name="email"
|
||||
:label="__('Email Address')"
|
||||
label="{{ __('Email Address') }}"
|
||||
type="email"
|
||||
required
|
||||
autofocus
|
||||
placeholder="email@example.com"
|
||||
icon="o-envelope"
|
||||
/>
|
||||
|
||||
<flux:button variant="primary" type="submit" class="w-full" data-test="email-password-reset-link-button">
|
||||
{{ __('Email password reset link') }}
|
||||
</flux:button>
|
||||
<x-button type="submit" label="{{ __('Email password reset link') }}" class="btn-primary w-full" data-test="email-password-reset-link-button" />
|
||||
</form>
|
||||
|
||||
<div class="space-x-1 rtl:space-x-reverse text-center text-sm text-zinc-400">
|
||||
<div class="space-x-1 rtl:space-x-reverse text-center text-sm opacity-60">
|
||||
<span>{{ __('Or, return to') }}</span>
|
||||
<flux:link :href="route('login')" wire:navigate>{{ __('log in') }}</flux:link>
|
||||
<a href="{{ route('login') }}" class="link link-primary" wire:navigate>{{ __('log in') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</x-layouts::auth>
|
||||
|
||||
@@ -9,50 +9,50 @@
|
||||
@csrf
|
||||
|
||||
<!-- Email Address -->
|
||||
<flux:input
|
||||
<x-input
|
||||
name="email"
|
||||
:label="__('Email address')"
|
||||
label="{{ __('Email address') }}"
|
||||
:value="old('email')"
|
||||
type="email"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="email"
|
||||
placeholder="email@example.com"
|
||||
icon="o-envelope"
|
||||
/>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="relative">
|
||||
<flux:input
|
||||
<x-password
|
||||
name="password"
|
||||
:label="__('Password')"
|
||||
type="password"
|
||||
label="{{ __('Password') }}"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
:placeholder="__('Password')"
|
||||
viewable
|
||||
placeholder="{{ __('Password') }}"
|
||||
/>
|
||||
|
||||
@if (Route::has('password.request'))
|
||||
<flux:link class="absolute top-0 text-sm end-0" :href="route('password.request')" wire:navigate>
|
||||
<a class="absolute top-0 text-sm end-0 link link-primary" href="{{ route('password.request') }}" wire:navigate>
|
||||
{{ __('Forgot your password?') }}
|
||||
</flux:link>
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Remember Me -->
|
||||
<flux:checkbox name="remember" :label="__('Remember me')" :checked="old('remember')" />
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="remember" class="checkbox checkbox-sm" {{ old('remember') ? 'checked' : '' }} />
|
||||
<span class="text-sm">{{ __('Remember me') }}</span>
|
||||
</label>
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<flux:button variant="primary" type="submit" class="w-full" data-test="login-button">
|
||||
{{ __('Log in') }}
|
||||
</flux:button>
|
||||
<x-button type="submit" label="{{ __('Log in') }}" class="btn-primary w-full" data-test="login-button" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@if (Route::has('register'))
|
||||
<div class="space-x-1 text-sm text-center rtl:space-x-reverse text-zinc-600 dark:text-zinc-400">
|
||||
<div class="space-x-1 text-sm text-center rtl:space-x-reverse opacity-60">
|
||||
<span>{{ __('Don\'t have an account?') }}</span>
|
||||
<flux:link :href="route('register')" wire:navigate>{{ __('Sign up') }}</flux:link>
|
||||
<a href="{{ route('register') }}" class="link link-primary" wire:navigate>{{ __('Sign up') }}</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -8,60 +8,56 @@
|
||||
<form method="POST" action="{{ route('register.store') }}" class="flex flex-col gap-6">
|
||||
@csrf
|
||||
<!-- Name -->
|
||||
<flux:input
|
||||
<x-input
|
||||
name="name"
|
||||
:label="__('Name')"
|
||||
label="{{ __('Name') }}"
|
||||
:value="old('name')"
|
||||
type="text"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="name"
|
||||
:placeholder="__('Full name')"
|
||||
placeholder="{{ __('Full name') }}"
|
||||
icon="o-user"
|
||||
/>
|
||||
|
||||
<!-- Email Address -->
|
||||
<flux:input
|
||||
<x-input
|
||||
name="email"
|
||||
:label="__('Email address')"
|
||||
label="{{ __('Email address') }}"
|
||||
:value="old('email')"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
placeholder="email@example.com"
|
||||
icon="o-envelope"
|
||||
/>
|
||||
|
||||
<!-- Password -->
|
||||
<flux:input
|
||||
<x-password
|
||||
name="password"
|
||||
:label="__('Password')"
|
||||
type="password"
|
||||
label="{{ __('Password') }}"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
:placeholder="__('Password')"
|
||||
viewable
|
||||
placeholder="{{ __('Password') }}"
|
||||
/>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<flux:input
|
||||
<x-password
|
||||
name="password_confirmation"
|
||||
:label="__('Confirm password')"
|
||||
type="password"
|
||||
label="{{ __('Confirm password') }}"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
:placeholder="__('Confirm password')"
|
||||
viewable
|
||||
placeholder="{{ __('Confirm password') }}"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<flux:button type="submit" variant="primary" class="w-full" data-test="register-user-button">
|
||||
{{ __('Create account') }}
|
||||
</flux:button>
|
||||
<x-button type="submit" label="{{ __('Create account') }}" class="btn-primary w-full" data-test="register-user-button" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="space-x-1 rtl:space-x-reverse text-center text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<div class="space-x-1 rtl:space-x-reverse text-center text-sm opacity-60">
|
||||
<span>{{ __('Already have an account?') }}</span>
|
||||
<flux:link :href="route('login')" wire:navigate>{{ __('Log in') }}</flux:link>
|
||||
<a href="{{ route('login') }}" class="link link-primary" wire:navigate>{{ __('Log in') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</x-layouts::auth>
|
||||
|
||||
@@ -11,41 +11,36 @@
|
||||
<input type="hidden" name="token" value="{{ request()->route('token') }}">
|
||||
|
||||
<!-- Email Address -->
|
||||
<flux:input
|
||||
<x-input
|
||||
name="email"
|
||||
value="{{ request('email') }}"
|
||||
:label="__('Email')"
|
||||
label="{{ __('Email') }}"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
icon="o-envelope"
|
||||
/>
|
||||
|
||||
<!-- Password -->
|
||||
<flux:input
|
||||
<x-password
|
||||
name="password"
|
||||
:label="__('Password')"
|
||||
type="password"
|
||||
label="{{ __('Password') }}"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
:placeholder="__('Password')"
|
||||
viewable
|
||||
placeholder="{{ __('Password') }}"
|
||||
/>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<flux:input
|
||||
<x-password
|
||||
name="password_confirmation"
|
||||
:label="__('Confirm password')"
|
||||
type="password"
|
||||
label="{{ __('Confirm password') }}"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
:placeholder="__('Confirm password')"
|
||||
viewable
|
||||
placeholder="{{ __('Confirm password') }}"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<flux:button type="submit" variant="primary" class="w-full" data-test="reset-password-button">
|
||||
{{ __('Reset password') }}
|
||||
</flux:button>
|
||||
<x-button type="submit" label="{{ __('Reset password') }}" class="btn-primary w-full" data-test="reset-password-button" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
<div class="space-y-5 text-center">
|
||||
<div x-show="!showRecoveryInput">
|
||||
<div class="flex items-center justify-center my-5">
|
||||
<flux:otp
|
||||
x-model="code"
|
||||
length="6"
|
||||
<div class="my-5">
|
||||
<x-input
|
||||
type="text"
|
||||
name="code"
|
||||
label="OTP Code"
|
||||
label:sr-only
|
||||
class="mx-auto"
|
||||
/>
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="showRecoveryInput">
|
||||
<div class="my-5">
|
||||
<flux:input
|
||||
<x-input
|
||||
type="text"
|
||||
name="recovery_code"
|
||||
x-ref="recovery_code"
|
||||
@@ -67,19 +66,15 @@
|
||||
</div>
|
||||
|
||||
@error('recovery_code')
|
||||
<flux:text color="red">
|
||||
{{ $message }}
|
||||
</flux:text>
|
||||
<p class="text-error text-sm">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<flux:button
|
||||
variant="primary"
|
||||
<x-button
|
||||
type="submit"
|
||||
class="w-full"
|
||||
>
|
||||
{{ __('Continue') }}
|
||||
</flux:button>
|
||||
label="{{ __('Continue') }}"
|
||||
class="btn-primary w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 space-x-0.5 text-sm leading-5 text-center">
|
||||
|
||||
@@ -1,28 +1,24 @@
|
||||
<x-layouts::auth>
|
||||
<div class="mt-4 flex flex-col gap-6">
|
||||
<flux:text class="text-center">
|
||||
<p class="text-center text-sm opacity-70">
|
||||
{{ __('Please verify your email address by clicking on the link we just emailed to you.') }}
|
||||
</flux:text>
|
||||
</p>
|
||||
|
||||
@if (session('status') == 'verification-link-sent')
|
||||
<flux:text class="text-center font-medium !dark:text-green-400 !text-green-600">
|
||||
<p class="text-center text-sm font-medium text-success">
|
||||
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
|
||||
</flux:text>
|
||||
</p>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-col items-center justify-between space-y-3">
|
||||
<form method="POST" action="{{ route('verification.send') }}">
|
||||
@csrf
|
||||
<flux:button type="submit" variant="primary" class="w-full">
|
||||
{{ __('Resend verification email') }}
|
||||
</flux:button>
|
||||
<x-button type="submit" label="{{ __('Resend verification email') }}" class="btn-primary w-full" />
|
||||
</form>
|
||||
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<flux:button variant="ghost" type="submit" class="text-sm cursor-pointer" data-test="logout-button">
|
||||
{{ __('Log out') }}
|
||||
</flux:button>
|
||||
<x-button type="submit" label="{{ __('Log out') }}" class="btn-ghost btn-sm" data-test="logout-button" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<div class="flex items-start max-md:flex-col">
|
||||
<div class="me-10 w-full pb-4 md:w-[220px]">
|
||||
<flux:navlist aria-label="{{ __('Settings') }}">
|
||||
<flux:navlist.item :href="route('profile.edit')" wire:navigate>{{ __('Profile') }}</flux:navlist.item>
|
||||
<flux:navlist.item :href="route('user-password.edit')" wire:navigate>{{ __('Password') }}</flux:navlist.item>
|
||||
<x-menu class="!p-0">
|
||||
<x-menu-item title="{{ __('Profile') }}" link="{{ route('profile.edit') }}" wire:navigate />
|
||||
<x-menu-item title="{{ __('Password') }}" link="{{ route('user-password.edit') }}" wire:navigate />
|
||||
@if (Laravel\Fortify\Features::canManageTwoFactorAuthentication())
|
||||
<flux:navlist.item :href="route('two-factor.show')" wire:navigate>{{ __('Two-Factor Auth') }}</flux:navlist.item>
|
||||
<x-menu-item title="{{ __('Two-Factor Auth') }}" link="{{ route('two-factor.show') }}" wire:navigate />
|
||||
@endif
|
||||
<flux:navlist.item :href="route('appearance.edit')" wire:navigate>{{ __('Appearance') }}</flux:navlist.item>
|
||||
</flux:navlist>
|
||||
<x-menu-item title="{{ __('Appearance') }}" link="{{ route('appearance.edit') }}" wire:navigate />
|
||||
</x-menu>
|
||||
</div>
|
||||
|
||||
<flux:separator class="md:hidden" />
|
||||
<div class="divider md:hidden"></div>
|
||||
|
||||
<div class="flex-1 self-stretch max-md:pt-6">
|
||||
<flux:heading>{{ $heading ?? '' }}</flux:heading>
|
||||
<flux:subheading>{{ $subheading ?? '' }}</flux:subheading>
|
||||
<h2 class="text-lg font-semibold">{{ $heading ?? '' }}</h2>
|
||||
<p class="text-sm opacity-60">{{ $subheading ?? '' }}</p>
|
||||
|
||||
<div class="mt-5 w-full max-w-lg">
|
||||
{{ $slot }}
|
||||
|
||||
@@ -46,55 +46,46 @@ new class extends Component {
|
||||
}; ?>
|
||||
|
||||
<div
|
||||
class="py-6 space-y-6 border shadow-sm rounded-xl border-zinc-200 dark:border-white/10"
|
||||
class="py-6 space-y-6 border shadow-sm rounded-xl border-base-300"
|
||||
wire:cloak
|
||||
x-data="{ showRecoveryCodes: false }"
|
||||
>
|
||||
<div class="px-6 space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:icon.lock-closed variant="outline" class="size-4"/>
|
||||
<flux:heading size="lg" level="3">{{ __('2FA Recovery Codes') }}</flux:heading>
|
||||
<x-icon name="o-lock-closed" class="w-4 h-4" />
|
||||
<h3 class="text-lg font-semibold">{{ __('2FA Recovery Codes') }}</h3>
|
||||
</div>
|
||||
<flux:text variant="subtle">
|
||||
<p class="text-sm opacity-60">
|
||||
{{ __('Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.') }}
|
||||
</flux:text>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="px-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<flux:button
|
||||
<x-button
|
||||
x-show="!showRecoveryCodes"
|
||||
icon="eye"
|
||||
icon:variant="outline"
|
||||
variant="primary"
|
||||
icon="o-eye"
|
||||
label="{{ __('View Recovery Codes') }}"
|
||||
class="btn-primary btn-sm"
|
||||
@click="showRecoveryCodes = true;"
|
||||
aria-expanded="false"
|
||||
aria-controls="recovery-codes-section"
|
||||
>
|
||||
{{ __('View Recovery Codes') }}
|
||||
</flux:button>
|
||||
/>
|
||||
|
||||
<flux:button
|
||||
<x-button
|
||||
x-show="showRecoveryCodes"
|
||||
icon="eye-slash"
|
||||
icon:variant="outline"
|
||||
variant="primary"
|
||||
icon="o-eye-slash"
|
||||
label="{{ __('Hide Recovery Codes') }}"
|
||||
class="btn-primary btn-sm"
|
||||
@click="showRecoveryCodes = false"
|
||||
aria-expanded="true"
|
||||
aria-controls="recovery-codes-section"
|
||||
>
|
||||
{{ __('Hide Recovery Codes') }}
|
||||
</flux:button>
|
||||
/>
|
||||
|
||||
@if (filled($recoveryCodes))
|
||||
<flux:button
|
||||
<x-button
|
||||
x-show="showRecoveryCodes"
|
||||
icon="arrow-path"
|
||||
variant="filled"
|
||||
icon="o-arrow-path"
|
||||
label="{{ __('Regenerate Codes') }}"
|
||||
class="btn-sm"
|
||||
wire:click="regenerateRecoveryCodes"
|
||||
>
|
||||
{{ __('Regenerate Codes') }}
|
||||
</flux:button>
|
||||
/>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -107,12 +98,12 @@ new class extends Component {
|
||||
>
|
||||
<div class="mt-3 space-y-3">
|
||||
@error('recoveryCodes')
|
||||
<flux:callout variant="danger" icon="x-circle" heading="{{$message}}"/>
|
||||
<div class="alert alert-error">{{ $message }}</div>
|
||||
@enderror
|
||||
|
||||
@if (filled($recoveryCodes))
|
||||
<div
|
||||
class="grid gap-1 p-4 font-mono text-sm rounded-lg bg-zinc-100 dark:bg-white/5"
|
||||
class="grid gap-1 p-4 font-mono text-sm rounded-lg bg-base-200"
|
||||
role="list"
|
||||
aria-label="{{ __('Recovery codes') }}"
|
||||
>
|
||||
@@ -126,9 +117,9 @@ new class extends Component {
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<flux:text variant="subtle" class="text-xs">
|
||||
<p class="text-xs opacity-60">
|
||||
{{ __('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.') }}
|
||||
</flux:text>
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,13 +9,7 @@ new class extends Component {
|
||||
<section class="w-full">
|
||||
@include('partials.settings-heading')
|
||||
|
||||
<flux:heading class="sr-only">{{ __('Appearance Settings') }}</flux:heading>
|
||||
|
||||
<x-pages::settings.layout :heading="__('Appearance')" :subheading="__('Update the appearance settings for your account')">
|
||||
<flux:radio.group x-data variant="segmented" x-model="$flux.appearance">
|
||||
<flux:radio value="light" icon="sun">{{ __('Light') }}</flux:radio>
|
||||
<flux:radio value="dark" icon="moon">{{ __('Dark') }}</flux:radio>
|
||||
<flux:radio value="system" icon="computer-desktop">{{ __('System') }}</flux:radio>
|
||||
</flux:radio.group>
|
||||
<x-theme-toggle />
|
||||
</x-pages::settings.layout>
|
||||
</section>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
<section class="mt-10 space-y-6">
|
||||
<div class="relative mb-5">
|
||||
<flux:heading>{{ __('Delete account') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Delete your account and all of its resources') }}</flux:subheading>
|
||||
<h3 class="text-lg font-semibold">{{ __('Delete account') }}</h3>
|
||||
<p class="text-sm opacity-60">{{ __('Delete your account and all of its resources') }}</p>
|
||||
</div>
|
||||
|
||||
<flux:modal.trigger name="confirm-user-deletion">
|
||||
<flux:button variant="danger" x-data="" x-on:click.prevent="$dispatch('open-modal', 'confirm-user-deletion')" data-test="delete-user-button">
|
||||
{{ __('Delete account') }}
|
||||
</flux:button>
|
||||
</flux:modal.trigger>
|
||||
<x-button
|
||||
label="{{ __('Delete account') }}"
|
||||
class="btn-error"
|
||||
@click="$wire.showDeleteModal = true"
|
||||
data-test="delete-user-button"
|
||||
/>
|
||||
|
||||
<flux:modal name="confirm-user-deletion" :show="$errors->isNotEmpty()" focusable class="max-w-lg">
|
||||
<form method="POST" wire:submit="deleteUser" class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Are you sure you want to delete your account?') }}</flux:heading>
|
||||
<x-modal wire:model="showDeleteModal" title="{{ __('Are you sure you want to delete your account?') }}">
|
||||
<p class="text-sm opacity-70">
|
||||
{{ __('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.') }}
|
||||
</p>
|
||||
|
||||
<flux:subheading>
|
||||
{{ __('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.') }}
|
||||
</flux:subheading>
|
||||
</div>
|
||||
<form wire:submit="deleteUser" class="space-y-6 mt-4">
|
||||
<x-password wire:model="password" label="{{ __('Password') }}" />
|
||||
|
||||
<flux:input wire:model="password" :label="__('Password')" type="password" />
|
||||
|
||||
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
|
||||
<flux:modal.close>
|
||||
<flux:button variant="filled">{{ __('Cancel') }}</flux:button>
|
||||
</flux:modal.close>
|
||||
|
||||
<flux:button variant="danger" type="submit" data-test="confirm-delete-user-button">
|
||||
{{ __('Delete account') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
<x-slot:actions>
|
||||
<x-button label="{{ __('Cancel') }}" @click="$wire.showDeleteModal = false" />
|
||||
<x-button type="submit" label="{{ __('Delete account') }}" class="btn-error" data-test="confirm-delete-user-button" />
|
||||
</x-slot:actions>
|
||||
</form>
|
||||
</flux:modal>
|
||||
</x-modal>
|
||||
</section>
|
||||
|
||||
@@ -42,37 +42,30 @@ new class extends Component {
|
||||
<section class="w-full">
|
||||
@include('partials.settings-heading')
|
||||
|
||||
<flux:heading class="sr-only">{{ __('Password Settings') }}</flux:heading>
|
||||
|
||||
<x-pages::settings.layout :heading="__('Update password')" :subheading="__('Ensure your account is using a long, random password to stay secure')">
|
||||
<form method="POST" wire:submit="updatePassword" class="mt-6 space-y-6">
|
||||
<flux:input
|
||||
<x-password
|
||||
wire:model="current_password"
|
||||
:label="__('Current password')"
|
||||
type="password"
|
||||
label="{{ __('Current password') }}"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<flux:input
|
||||
<x-password
|
||||
wire:model="password"
|
||||
:label="__('New password')"
|
||||
type="password"
|
||||
label="{{ __('New password') }}"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<flux:input
|
||||
<x-password
|
||||
wire:model="password_confirmation"
|
||||
:label="__('Confirm Password')"
|
||||
type="password"
|
||||
label="{{ __('Confirm Password') }}"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center justify-end">
|
||||
<flux:button variant="primary" type="submit" class="w-full" data-test="update-password-button">
|
||||
{{ __('Save') }}
|
||||
</flux:button>
|
||||
<x-button type="submit" label="{{ __('Save') }}" class="btn-primary" spinner="updatePassword" data-test="update-password-button" />
|
||||
</div>
|
||||
|
||||
<x-action-message class="me-3" on="password-updated">
|
||||
|
||||
@@ -79,29 +79,27 @@ new class extends Component {
|
||||
<section class="w-full">
|
||||
@include('partials.settings-heading')
|
||||
|
||||
<flux:heading class="sr-only">{{ __('Profile Settings') }}</flux:heading>
|
||||
|
||||
<x-pages::settings.layout :heading="__('Profile')" :subheading="__('Update your name and email address')">
|
||||
<form wire:submit="updateProfileInformation" class="my-6 w-full space-y-6">
|
||||
<flux:input wire:model="name" :label="__('Name')" type="text" required autofocus autocomplete="name" />
|
||||
<x-input wire:model="name" label="{{ __('Name') }}" type="text" required autofocus autocomplete="name" icon="o-user" />
|
||||
|
||||
<div>
|
||||
<flux:input wire:model="email" :label="__('Email')" type="email" required autocomplete="email" />
|
||||
<x-input wire:model="email" label="{{ __('Email') }}" type="email" required autocomplete="email" icon="o-envelope" />
|
||||
|
||||
@if ($this->hasUnverifiedEmail)
|
||||
<div>
|
||||
<flux:text class="mt-4">
|
||||
<p class="mt-4 text-sm opacity-70">
|
||||
{{ __('Your email address is unverified.') }}
|
||||
|
||||
<flux:link class="text-sm cursor-pointer" wire:click.prevent="resendVerificationNotification">
|
||||
<a class="link link-primary text-sm cursor-pointer" wire:click.prevent="resendVerificationNotification">
|
||||
{{ __('Click here to re-send the verification email.') }}
|
||||
</flux:link>
|
||||
</flux:text>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@if (session('status') === 'verification-link-sent')
|
||||
<flux:text class="mt-2 font-medium !dark:text-green-400 !text-green-600">
|
||||
<p class="mt-2 text-sm font-medium text-success">
|
||||
{{ __('A new verification link has been sent to your email address.') }}
|
||||
</flux:text>
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@@ -109,9 +107,7 @@ new class extends Component {
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center justify-end">
|
||||
<flux:button variant="primary" type="submit" class="w-full" data-test="update-profile-button">
|
||||
{{ __('Save') }}
|
||||
</flux:button>
|
||||
<x-button type="submit" label="{{ __('Save') }}" class="btn-primary" spinner="updateProfileInformation" data-test="update-profile-button" />
|
||||
</div>
|
||||
|
||||
<x-action-message class="me-3" on="profile-updated">
|
||||
|
||||