Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
461bc23f0a | ||
|
|
cd97612b4d | ||
|
|
c95d0c43c2 | ||
|
|
09e24ade14 | ||
|
|
21bea9646d | ||
|
|
27e322c352 | ||
|
|
ca0dfa9396 | ||
|
|
d860a16551 | ||
|
|
606cc766f2 | ||
|
|
e49bdf3eb3 | ||
|
|
4470c2ed87 |
@@ -5,5 +5,5 @@ paths:
|
||||
|
||||
# Css
|
||||
|
||||
## Regenerate the colour scheme, never hand-edit it
|
||||
material-scheme.css and material-scheme.json are generated together by `php artisan material:scheme "#4f46e5" --variant=vibrant` (Vibrant was chosen over Tonal Spot, which read washed out on the indigo seed). The JSON colours the Markdown mail theme and the fallback error pages, so a hand edit to the CSS alone leaves them out of step. Change the seed or variant and rerun the command instead.
|
||||
## Regenerate the colour profiles, never hand-edit the scheme
|
||||
material-scheme.css and material-scheme.json are generated together by `php artisan material:scheme` (no seed) from the eight `profiles` in config/livewire-material.php — all Vibrant (chosen over Tonal Spot, which read washed out on indigo) except Graphite (Neutral); `profile` is the default, indigo. The JSON colours the Markdown mail theme and the fallback error pages and lists the profiles Admin settings offers and validates against, so a hand edit to the CSS alone leaves them out of step. Change the config and rerun the command; a profile only exists once generated. The admin's choice is the `color_profile` setting, read through `Scheme::resolveProfileUsing()` in AppServiceProvider.
|
||||
|
||||
@@ -7,3 +7,5 @@ Before planning or editing, find the row whose globs match the file's path and r
|
||||
| config/livewire-material.php | .ai/rules/config.md |
|
||||
| resources/css/material-scheme.* | .ai/rules/css.md |
|
||||
| resources/views/livewire/share-download.blade.php | .ai/rules/livewire.md |
|
||||
| tests/Screenshots/** | .ai/rules/screenshots.md |
|
||||
| website/** | .ai/rules/website.md |
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
paths:
|
||||
- 'tests/Screenshots/**'
|
||||
---
|
||||
|
||||
# Screenshots
|
||||
|
||||
## Screenshots come from composer screenshots, before a release
|
||||
Run `composer screenshots` whenever the interface changes and before a release; it builds assets and runs tests/Screenshots (not part of any test suite or CI), publishing WebP files to website/img/screenshots. Demo data (DemoData) and the clock are fixed so runs are reproducible. Traps: Pest only starts its browser for a test whose body calls `visit(` after whitespace; Livewire's temporary-upload cleanup must stay off under the frozen clock or it deletes the selected files; the in-process server's random port is shown as https://files.example.com and the QR redrawn for it; upload_max_filesize/post_max_size are set to 4G by the script so the admin settings hint does not show the machine's PHP limit.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
paths:
|
||||
- 'website/**'
|
||||
---
|
||||
|
||||
# Website
|
||||
|
||||
## website/ is the live site, uploaded by hand
|
||||
website/ is a faithful copy of sealshare.nonameweb.ch (METANET hosting), hand-written HTML/CSS with no build step, uploaded wholesale when it changes. Colours in css/theme.css are copied from the indigo profile (the JSON's top-level light/dark) in resources/css/material-scheme.json — copy them again if indigo is regenerated differently; the site does not follow the admin's colour profile. The comparison tables are dated and every competitor value has a source from the product's own site, docs or repo; an unsourced value is "—", never a guess. Never call SealShare's encryption end-to-end (it encrypts at rest on the server). Nothing may load from another host except plausible.io. tests/Feature/WebsiteTest.php guards all of this.
|
||||
@@ -46,6 +46,34 @@ php artisan material:scheme "#4f46e5" --variant=tonal-spot
|
||||
|
||||
Variants: `tonal-spot` (M3's default), `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--success`, `--warning` and `--info` set the source of the state colours; `--contrast` goes from -1 to 1. The command also writes `material-scheme.json` beside the stylesheet.
|
||||
|
||||
### Colour profiles
|
||||
|
||||
An installation that switches between several schemes lists them in `config/livewire-material.php` and runs the command without a seed, which generates every profile into the same stylesheet, keyed by `<html data-scheme>`:
|
||||
|
||||
```php
|
||||
'profiles' => [
|
||||
'indigo' => ['label' => 'Indigo', 'seed' => '#4f46e5', 'variant' => 'vibrant'],
|
||||
'teal' => ['label' => 'Teal', 'seed' => '#00897b', 'variant' => 'vibrant'],
|
||||
],
|
||||
'profile' => 'indigo', // the default; else the first
|
||||
```
|
||||
|
||||
```bash
|
||||
php artisan material:scheme
|
||||
```
|
||||
|
||||
- Names are lowercase letters, digits and dashes. Regenerate after changing the list; only generated profiles exist for the picker, the resolver and the stylesheet.
|
||||
- The application says which profile is active, once, in a service provider. The closure runs every time a colour is drawn (head script, mail, error page), so it may read the database; a name that is not a generated profile, or a closure that throws, falls back to the default:
|
||||
|
||||
```php
|
||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
|
||||
Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile'));
|
||||
```
|
||||
|
||||
- `<x-theme-script>` writes the active profile to `<html data-scheme>` before the first paint; mails and error pages draw it too. `Scheme::profiles()` lists the generated profiles (name ⇒ label, light and dark roles) and `Scheme::profile()` names the active one — validate a stored choice with `Rule::in(array_keys(Scheme::profiles()))`.
|
||||
- Choose with `<x-scheme-picker wire:model="colorProfile" />` (see Components). Never set `data-scheme` on an element inside the page expecting a different profile there: profiles key on `<html>`.
|
||||
|
||||
## Tokens
|
||||
|
||||
Tailwind's default palette is cleared: every colour class names an M3 role. `text-red-600`, `bg-base-200` or `text-gray-500` compile to nothing.
|
||||
@@ -62,7 +90,7 @@ Tailwind's default palette is cleared: every colour class names an M3 role. `tex
|
||||
|
||||
## Theme
|
||||
|
||||
`config/livewire-material.php` → `theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys`. In Alpine, `$store.theme` holds `choice` (what the visitor picked), `resolved` (`light` or `dark`, what shows), `set('light'|'dark'|'system')` and `toggle()`; `x-model="$store.theme.value"` binds a control.
|
||||
`config/livewire-material.php` → `theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys`. In Alpine, `$store.theme` holds `choice` (what the visitor picked), `resolved` (`light` or `dark`, what shows), `set('light'|'dark'|'system')` and `toggle()`; `x-model="$store.theme.value"` binds a control. With colour profiles it also holds `scheme` (the profile on screen) and `previewScheme(name)`, which shows another profile on this page without storing anything.
|
||||
|
||||
## Toasts
|
||||
|
||||
@@ -724,6 +752,14 @@ An avatar that opens a menu: `name`, `email`, `avatar` (image URL or initials; d
|
||||
|
||||
Switches `$store.theme`: `mode="toggle"` (default, light/dark icon button), `cycle` (light → dark → system), `picker` (segmented buttons for settings pages). Every toggle on a page shares the store.
|
||||
|
||||
### `<x-scheme-picker>`
|
||||
|
||||
A choice of colour profile (see Colour profiles): a swatch per generated profile — its name and its primary, secondary and tertiary colour — over native radios. `wire:model` or `x-model` (with `name`) binds the chosen name; choosing previews it on the page at once; storing it is the application's. `label`, `hint`, `name`, `profiles` (default `Scheme::profiles()`). A validation error for the bound property replaces the hint. Without profiles it renders nothing.
|
||||
|
||||
```blade
|
||||
<x-scheme-picker :label="__('Colour profile')" wire:model="colorProfile" :hint="__('Applies to every page after saving')" />
|
||||
```
|
||||
|
||||
### `<x-table>`, `<x-sort-header>`
|
||||
|
||||
A data table: write plain `<thead>`, `<tr>`, `<th>`, `<td>` inside `<x-table>` (`size="xs"` for a dense one); cell utilities (`text-end`, `whitespace-nowrap`) always win. Scrolling is yours: wrap it in `<div class="overflow-x-auto">`. A row that opens something is `data-list-row` with one `data-list-open` control; a selected row is `aria-selected="true"`.
|
||||
|
||||
@@ -21,10 +21,13 @@ docker-compose*.yml
|
||||
Dockerfile
|
||||
|
||||
# CI/CD
|
||||
.github
|
||||
.gitea
|
||||
|
||||
# Testing
|
||||
tests
|
||||
|
||||
# Website (published separately)
|
||||
website
|
||||
phpunit.xml
|
||||
.phpunit.cache
|
||||
|
||||
|
||||
@@ -8,4 +8,4 @@
|
||||
|
||||
CHANGELOG.md export-ignore
|
||||
README.md export-ignore
|
||||
.github/workflows/browser-tests.yml export-ignore
|
||||
.gitea export-ignore
|
||||
|
||||
@@ -67,25 +67,33 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# The runner is an arm64 server, so the amd64 image's final stage is emulated. QEMU 8.x
|
||||
# crashes running x86_64 programs on an arm64 host (QEMU issue 2168, "QEMU internal
|
||||
# SIGSEGV {code=MAPERR, addr=0x20}") and 10.2 segfaults on this runner too; 9.2.2 was
|
||||
# checked on the runner's host: node, composer and install-php-extensions all run.
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v9.2.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
# Gitea's job token cannot publish packages yet: REGISTRY_TOKEN is an access token with
|
||||
# package write rights, owned by the account that pushes (gitea.actor).
|
||||
- name: Log in to the Gitea container registry
|
||||
if: gitea.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
registry: gitea.nonameweb.ch
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
images: gitea.nonameweb.ch/nonameweb/sealshare
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
@@ -98,21 +106,27 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: ${{ gitea.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-from: type=registry,ref=gitea.nonameweb.ch/nonameweb/sealshare:buildcache
|
||||
cache-to: ${{ gitea.event_name != 'pull_request' && 'type=registry,ref=gitea.nonameweb.ch/nonameweb/sealshare:buildcache,mode=max' || '' }}
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-and-push
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
- name: Take the release notes from the changelog
|
||||
run: |
|
||||
version="${{ gitea.ref_name }}"
|
||||
awk -v heading="## [${version#v}]" 'index($0, heading) == 1 { found = 1; next } found && /^## \[/ { exit } found { print }' CHANGELOG.md > release-notes.md
|
||||
test -s release-notes.md
|
||||
|
||||
- name: Create the Gitea release
|
||||
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||
with:
|
||||
generate_release_notes: true
|
||||
body_path: release-notes.md
|
||||
@@ -44,4 +44,4 @@ jobs:
|
||||
# commit_options: '--no-verify'
|
||||
# file_pattern: |
|
||||
# **/*
|
||||
# !.github/workflows/*
|
||||
# !.gitea/workflows/*
|
||||
@@ -25,3 +25,8 @@ yarn-error.log
|
||||
**/caddy
|
||||
frankenphp
|
||||
frankenphp-worker.php
|
||||
|
||||
/tests/Browser/Screenshots
|
||||
|
||||
# Planning notes stay local
|
||||
/docs/plans
|
||||
|
||||
@@ -7,15 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.0.1] - 2026-09-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- The 2.0.0 Docker image did not start: the entrypoint's `php artisan view:cache` failed with "Unable to locate a class or view for component [showcase::example]", because Livewire Material only registered its showcase components while the showcase was enabled, which it is not in production. Livewire Material 1.1.1 registers them always, and a test now caches every view as the entrypoint does.
|
||||
|
||||
## [2.0.0] - 2026-09-13
|
||||
|
||||
### Added
|
||||
|
||||
- Eight colour profiles — Indigo (the default), Blue, Teal, Green, Amber, Rose, Violet and Graphite. An admin picks one in Admin settings, previews it on the page, and after saving every page, mail and error page uses it; light and dark stay each visitor's own choice.
|
||||
- The share created page offers the link as a QR code: "Show QR code" opens it in a dialog (full screen on a phone) and "Download" saves it as a PNG. For a password-protected share the dialog reminds that recipients also need the password; the code holds only the link.
|
||||
- A "Share…" button on the same page opens the device's share sheet with the link, where the browser has one (mostly phones and Safari).
|
||||
|
||||
### Changed
|
||||
|
||||
- SealShare moved from GitHub to Gitea: the source is at https://gitea.nonameweb.ch/noNameWEB/SealShare, and the Docker image is published as `gitea.nonameweb.ch/nonameweb/sealshare`. Images at `ghcr.io/surtic86/sealshare` are no longer updated — change `image:` in your `docker-compose.yml` to the new name to keep receiving releases.
|
||||
- The interface is rebuilt on [Livewire Material](https://gitea.nonameweb.ch/noNameWEB/livewire-material), a Material 3 Expressive component library, replacing Mary UI and DaisyUI. Every page — upload, share created, download, sign-in, settings, admin and the setup wizard — uses its components, in a colour scheme generated from SealShare's indigo.
|
||||
- The theme follows the system's light or dark setting until a user picks Light, Dark or System in Settings → Appearance, or from the account menu. A theme chosen in 1.x is kept.
|
||||
- A floating toolbar centred at the bottom of every page replaces the sidebar and header layouts. Signed-in users reach Upload and the admin pages from it, and Settings, the theme and Log out from its account menu. The site's name and logo head the upload and download pages.
|
||||
@@ -28,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- The README called SealShare's encryption end-to-end. Files are encrypted at rest on the server; the README now says so, and that a share password's key is never stored.
|
||||
- The admin dashboard passed its sort column and direction straight to the query, so a crafted Livewire request could order shares by any column or cause a server error. It now sorts only by the columns it shows and otherwise falls back to newest first.
|
||||
|
||||
## [1.2.0] - 2026-09-10
|
||||
@@ -104,9 +113,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Dark themed UI built with Livewire, Alpine.js, Tailwind CSS and DaisyUI.
|
||||
- Docker images published to `ghcr.io/surtic86/sealshare`, served by FrankenPHP via Laravel Octane.
|
||||
|
||||
[Unreleased]: https://github.com/surtic86/SealShare/compare/v2.0.0...HEAD
|
||||
[2.0.0]: https://github.com/surtic86/SealShare/compare/v1.2.0...v2.0.0
|
||||
[1.2.0]: https://github.com/surtic86/SealShare/compare/v1.1.0...v1.2.0
|
||||
[1.1.0]: https://github.com/surtic86/SealShare/compare/v1.0.1...v1.1.0
|
||||
[1.0.1]: https://github.com/surtic86/SealShare/compare/v1.0.0...v1.0.1
|
||||
[1.0.0]: https://github.com/surtic86/SealShare/releases/tag/v1.0.0
|
||||
[Unreleased]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.0.1...main
|
||||
[2.0.1]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.0.0...v2.0.1
|
||||
[2.0.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/releases/tag/v2.0.0
|
||||
|
||||
@@ -195,7 +195,7 @@ This application uses `nonameweb/livewire-material`: Material 3 Expressive compo
|
||||
|
||||
- Components are anonymous Blade components, unprefixed unless `config/livewire-material.php` sets a `prefix`. Before writing or changing a view that uses them, activate the `livewire-material-development` skill for the props, slots and traps of each component.
|
||||
- Never write maryUI tags (`<x-mary-*>`) or daisyUI classes (`btn`, `card`, `badge`, `bg-base-200`, `text-base-content`…). They compile to nothing and fail silently.
|
||||
- Every layout includes `<x-theme-script />` in `<head>` before `@vite`. The colour scheme is generated with `php artisan material:scheme` — never edit `resources/css/material-scheme.css` by hand.
|
||||
- Every layout includes `<x-theme-script />` in `<head>` before `@vite`. The colour scheme is generated with `php artisan material:scheme` — never edit `resources/css/material-scheme.css` by hand. With colour profiles (`livewire-material.profiles`), run it without a seed after changing them; the active profile comes from `Scheme::resolveProfileUsing()`.
|
||||
- While the application runs locally, every token and component renders in the application's own scheme at `/material` (the showcase).
|
||||
- HTTP error pages and the Markdown mail theme come from the package. Change error wording by publishing `--tag=livewire-material-errors`; select the mail theme with `MAIL_MARKDOWN_THEME=livewire-material::mail.theme`.
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# ============================================
|
||||
# Stage 1: Install PHP dependencies
|
||||
# ============================================
|
||||
FROM composer:2 AS vendor
|
||||
# Built on the build machine's own platform: vendor/ is plain PHP, the same for every target, so a
|
||||
# multi-arch build runs it once and never under emulation.
|
||||
FROM --platform=$BUILDPLATFORM composer:2 AS vendor
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -21,8 +23,9 @@ RUN composer dump-autoload --optimize --no-dev
|
||||
# ============================================
|
||||
# Stage 2: Build frontend assets
|
||||
# ============================================
|
||||
# After Composer: the stylesheet and script import Livewire Material from vendor/.
|
||||
FROM node:24-alpine AS assets
|
||||
# After Composer: the stylesheet and script import Livewire Material from vendor/. On the build
|
||||
# machine's platform too: the output is CSS and JavaScript, whatever the target.
|
||||
FROM --platform=$BUILDPLATFORM node:24-alpine AS assets
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -41,7 +44,7 @@ RUN npm run build
|
||||
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.source="https://gitea.nonameweb.ch/noNameWEB/SealShare"
|
||||
LABEL org.opencontainers.image.description="Self-hosted encrypted file sharing"
|
||||
|
||||
# Install required PHP extensions
|
||||
@@ -84,7 +87,7 @@ COPY --from=vendor /app/vendor ./vendor
|
||||
COPY --from=assets /app/public/build ./public/build
|
||||
|
||||
# Remove dev/build files and stale cache not needed in production
|
||||
RUN rm -rf node_modules tests .github docker/dev.Dockerfile docker/dev-entrypoint.sh .env .env.example \
|
||||
RUN rm -rf node_modules tests .gitea docker/dev.Dockerfile docker/dev-entrypoint.sh .env .env.example \
|
||||
bootstrap/cache/*.php \
|
||||
&& mkdir -p storage/app/shares storage/app/public storage/framework/cache \
|
||||
storage/framework/sessions storage/framework/testing storage/framework/views \
|
||||
|
||||
@@ -2,11 +2,23 @@
|
||||
|
||||
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.
|
||||
|
||||
**Website:** [sealshare.nonameweb.ch](https://sealshare.nonameweb.ch)
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p>
|
||||
<img src="website/img/screenshots/desktop/light/01-upload-800.webp" alt="Uploading files and folders with share options" width="49%">
|
||||
<img src="website/img/screenshots/desktop/light/02-share-created-800.webp" alt="A new share with its link and QR code button" width="49%">
|
||||
</p>
|
||||
<p>
|
||||
<img src="website/img/screenshots/phone/light/03-download-540.webp" alt="The recipient's download page on a phone" width="30%">
|
||||
</p>
|
||||
|
||||
## 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, also as a QR code (saved as a PNG) or through the device's share sheet
|
||||
- **End-to-End Encryption** — All files encrypted at rest using AES-256-GCM (chunked, streaming)
|
||||
- **Encryption at Rest** — Files are encrypted on the server as they arrive, with AES-256-GCM (chunked, streaming); with a share password the key is derived from it and never stored. It is not end-to-end encryption: the server handles the files unencrypted while they are uploaded and downloaded
|
||||
- **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
|
||||
@@ -15,6 +27,7 @@ A simple, self-hosted file sharing solution built with Laravel. Upload files, ge
|
||||
- **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
|
||||
- **Colour Profiles** — Eight Material 3 colour profiles (Indigo, Blue, Teal, Green, Amber, Rose, Violet, Graphite); the admin picks one for every page, mail and error page
|
||||
- **System Password** — Optional global password gate to restrict upload access
|
||||
- **User Authentication** — Login, password reset, email verification
|
||||
- **Two-Factor Authentication** — TOTP-based 2FA via Laravel Fortify
|
||||
@@ -56,11 +69,11 @@ The app is available at `http://localhost:8000` with Vite HMR on port `5173`.
|
||||
|
||||
```bash
|
||||
mkdir sealshare && cd sealshare
|
||||
curl -O https://raw.githubusercontent.com/surtic86/SealShare/main/docker-compose.example.yml
|
||||
curl -O https://gitea.nonameweb.ch/noNameWEB/SealShare/raw/branch/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
|
||||
docker run --rm gitea.nonameweb.ch/nonameweb/sealshare:latest php artisan key:generate --show
|
||||
|
||||
# Edit docker-compose.yml — set APP_KEY, APP_URL, and SERVER_NAME
|
||||
# Then start:
|
||||
@@ -102,7 +115,7 @@ Behind a reverse proxy, raise its request body limit and read timeout as well (n
|
||||
### Manual (without Docker)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/surtic86/SealShare.git
|
||||
git clone https://gitea.nonameweb.ch/noNameWEB/SealShare.git
|
||||
cd SealShare
|
||||
|
||||
composer install --no-dev --optimize-autoloader
|
||||
|
||||
@@ -5,10 +5,12 @@ namespace App\Livewire\Admin;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
use NoNameWeb\LivewireMaterial\Concerns\Toasts;
|
||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class AdminSettings extends Component
|
||||
@@ -16,6 +18,9 @@ class AdminSettings extends Component
|
||||
use Toasts;
|
||||
use WithFileUploads;
|
||||
|
||||
/** The colour profile every page, mail and error page wears (config/livewire-material.php). */
|
||||
public string $colorProfile = '';
|
||||
|
||||
public string $systemPassword = '';
|
||||
|
||||
public string $defaultExpiration = '';
|
||||
@@ -44,6 +49,7 @@ class AdminSettings extends Component
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->colorProfile = Scheme::profile() ?? '';
|
||||
$this->defaultExpiration = Setting::get('default_expiration', '') ?? '';
|
||||
$this->maxFileSize = min(
|
||||
(int) Setting::get('max_file_size', 100 * 1024 * 1024) / (1024 * 1024),
|
||||
@@ -83,6 +89,7 @@ class AdminSettings extends Component
|
||||
$phpMaxMb = self::phpMaxUploadMb();
|
||||
|
||||
$this->validate([
|
||||
'colorProfile' => ['required', 'string', Rule::in(array_keys(Scheme::profiles()))],
|
||||
'maxFileSize' => ['required', 'integer', 'min:1', 'max:'.$phpMaxMb],
|
||||
'maxStorageQuota' => ['required', 'integer', 'min:1'],
|
||||
'maxFilesPerShare' => ['required', 'integer', 'min:1'],
|
||||
@@ -98,6 +105,7 @@ class AdminSettings extends Component
|
||||
Setting::set('system_password', Hash::make($this->systemPassword));
|
||||
}
|
||||
|
||||
Setting::set('color_profile', $this->colorProfile);
|
||||
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);
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Date;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -24,6 +26,10 @@ class AppServiceProvider extends ServiceProvider
|
||||
public function boot(): void
|
||||
{
|
||||
$this->configureDefaults();
|
||||
|
||||
// The colour profile the admin chose in Admin settings; asked each time a page, mail or
|
||||
// error page draws its colours, so a new choice applies at once in every Octane worker.
|
||||
Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -84,6 +84,11 @@
|
||||
],
|
||||
"pre-package-uninstall": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
|
||||
],
|
||||
"screenshots": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npm run build",
|
||||
"@php -d upload_max_filesize=4G -d post_max_size=4G vendor/bin/pest tests/Screenshots"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
|
||||
@@ -2999,11 +2999,11 @@
|
||||
},
|
||||
{
|
||||
"name": "nonameweb/livewire-material",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://gitea.nonameweb.ch/noNameWEB/livewire-material.git",
|
||||
"reference": "7798352cfc71643a88d4658f9704e65c4e24e8da"
|
||||
"reference": "db179e6b4e380bbd6aba2d3766ffcc84e58a8dd2"
|
||||
},
|
||||
"require": {
|
||||
"laravel/framework": "^13.0",
|
||||
@@ -3083,7 +3083,7 @@
|
||||
"material-design",
|
||||
"tailwindcss"
|
||||
],
|
||||
"time": "2026-09-13T09:16:52+00:00"
|
||||
"time": "2026-09-13T15:40:33+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nunomaduro/termwind",
|
||||
|
||||
@@ -107,6 +107,32 @@ return [
|
||||
|
||||
'scheme' => resource_path('css/material-scheme.json'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Colour profiles
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The profiles an admin chooses between in Admin settings. Each one is a
|
||||
| 'label', a 'seed' (#rrggbb), a 'variant' and an optional 'contrast'.
|
||||
| `php artisan material:scheme` (without a seed) generates them all into
|
||||
| resources/css/material-scheme.css; regenerate after changing this list.
|
||||
| 'profile' is the default, until an admin chooses.
|
||||
|
|
||||
*/
|
||||
|
||||
'profiles' => [
|
||||
'indigo' => ['label' => 'Indigo', 'seed' => '#4f46e5', 'variant' => 'vibrant'],
|
||||
'blue' => ['label' => 'Blue', 'seed' => '#0b57d0', 'variant' => 'vibrant'],
|
||||
'teal' => ['label' => 'Teal', 'seed' => '#00897b', 'variant' => 'vibrant'],
|
||||
'green' => ['label' => 'Green', 'seed' => '#2e7d32', 'variant' => 'vibrant'],
|
||||
'amber' => ['label' => 'Amber', 'seed' => '#e8710a', 'variant' => 'vibrant'],
|
||||
'rose' => ['label' => 'Rose', 'seed' => '#c2185b', 'variant' => 'vibrant'],
|
||||
'violet' => ['label' => 'Violet', 'seed' => '#6750a4', 'variant' => 'vibrant'],
|
||||
'graphite' => ['label' => 'Graphite', 'seed' => '#5f6368', 'variant' => 'neutral'],
|
||||
],
|
||||
|
||||
'profile' => 'indigo',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Mail
|
||||
|
||||
@@ -8,6 +8,9 @@ services:
|
||||
- "5173:5173"
|
||||
volumes:
|
||||
- .:/app
|
||||
# Its own node_modules: npm installs the build tools' native binaries for Linux here and for
|
||||
# the host's platform there, and a shared folder only ever holds one of them.
|
||||
- /app/node_modules
|
||||
environment:
|
||||
APP_KEY: ${APP_KEY:-}
|
||||
APP_URL: http://localhost:8000
|
||||
|
||||
@@ -18,7 +18,7 @@ services:
|
||||
# SealShare Application (FrankenPHP/Octane)
|
||||
# ------------------------------------------
|
||||
app:
|
||||
image: ghcr.io/surtic86/sealshare:latest
|
||||
image: gitea.nonameweb.ch/nonameweb/sealshare:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80" # HTTP
|
||||
@@ -71,7 +71,7 @@ services:
|
||||
# Scheduler - Runs cleanup for expired shares
|
||||
# ------------------------------------------
|
||||
scheduler:
|
||||
image: ghcr.io/surtic86/sealshare:latest
|
||||
image: gitea.nonameweb.ch/nonameweb/sealshare:latest
|
||||
restart: unless-stopped
|
||||
entrypoint: ["php", "artisan", "schedule:work"]
|
||||
volumes:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
app:
|
||||
image: ghcr.io/surtic86/sealshare:latest
|
||||
image: gitea.nonameweb.ch/nonameweb/sealshare:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
@@ -47,7 +47,7 @@ services:
|
||||
retries: 3
|
||||
|
||||
scheduler:
|
||||
image: ghcr.io/surtic86/sealshare:latest
|
||||
image: gitea.nonameweb.ch/nonameweb/sealshare:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
# SealShare on Livewire Material (2.0.0)
|
||||
|
||||
> The package itself — its decisions, the wave plan (Phases 1–10) and its tests — moved to
|
||||
> the package repo on 2026-09-13: [noNameWEB/livewire-material · docs/plans/livewire-material.md](https://gitea.nonameweb.ch/noNameWEB/livewire-material/src/branch/main/docs/plans/livewire-material.md).
|
||||
> This file keeps what SealShare does once the package reaches `1.0.0`.
|
||||
|
||||
## Goal
|
||||
|
||||
SealShare's UI is maryUI 2.9 on daisyUI 5 — a generic web-page look. After this change it runs
|
||||
on **`nonameweb/livewire-material` `^1.0`**: a clean, calm indigo Material 3 Expressive app with
|
||||
a top app bar, light / dark / system theme, and two Expressive moments — the upload drop zone
|
||||
and "link ready" — shipped as SealShare 2.0.0.
|
||||
|
||||
## Context
|
||||
|
||||
**Stacks.** SealShare: Laravel 13.31, Livewire 4.4, maryUI 2.9.10 (no prefix), daisyUI 5.7,
|
||||
Tailwind 4.3, Pest 5.1, Octane on FrankenPHP, PHP 8.5; public on GitHub under MIT, image
|
||||
published to `ghcr.io/surtic86/sealshare`. ReStride: same Laravel / Livewire / Tailwind / Pest,
|
||||
private on `gitea.nonameweb.ch`, CI through Gitea act_runner.
|
||||
|
||||
**SealShare's UI surface** (inventory, 2026-09-13):
|
||||
|
||||
- maryUI tags: `button` 30, `input` 18, `password` 16, `icon` 14, `card` 9 (6 `actions`
|
||||
slots), `menu`/`menu-item` 1/4 (settings nav), `theme-toggle` 3, `toggle` 2, `select` 2,
|
||||
`modal` 2, `table` 1 (`:headers :rows :sort-by with-pagination`, `@scope`), `textarea` 1,
|
||||
`toast` 1 (never triggered).
|
||||
- Raw daisyUI: `btn` (+ `-primary/-ghost/-sm/-xs/-error/-outline/-disabled`), `alert` ×6,
|
||||
`card`/`card-body` (4 admin stat tiles), `join` (2 copy fields), `progress` ×2,
|
||||
`loading` ×2, `badge-success/-error`, `divider`, `link link-primary` ×5, `label`,
|
||||
`file-input`, `checkbox`; tokens `bg-base-*`, `border-base-300`, `text-error/success`,
|
||||
`border-primary(/50)`, `bg-primary/5`; raw `text-green-600`, `bg-white` (QR code).
|
||||
Secondary text is `opacity-50/60/70`.
|
||||
- 20 Heroicons (outline), through `blade-heroicons` pulled in transitively by maryUI.
|
||||
- No `Mary\` PHP coupling. Admin settings flashes `session('message')` into an alert
|
||||
(`AdminSettings.php:116,128,135`). 3 `wire:confirm`.
|
||||
- Layouts: `layouts/app` → `app/sidebar` (centered `max-w-5xl` + footer nav), used by the
|
||||
Livewire pages and all settings SFCs (`config/livewire.php:47`); `layouts/auth` →
|
||||
`auth/simple`. The theme script sits *outside* `<head>` and hard-codes dark, while maryUI's
|
||||
toggle defaults from the OS. `partials/head` loads Instrument Sans from fonts.bunny.net.
|
||||
- Dead: `/dashboard` (starter placeholder, and Fortify's `home`), `welcome`,
|
||||
`pages/auth/register` (still referenced by `Fortify::registerView`,
|
||||
`FortifyServiceProvider.php:52`), `layouts/app/header`, `layouts/auth/{card,split}`,
|
||||
`components/app-logo`, `components/desktop-user-menu`, `components/placeholder-pattern`;
|
||||
the `alpinejs` npm dependency; the Flux credentials step in `tests.yml` and `docker.yml`.
|
||||
- Settings `profile` and `password` show "Saved." through `components/action-message`,
|
||||
listening for `profile-updated` / `password-updated`; `partials/settings-heading` uses a
|
||||
daisyUI `divider`. The 3 `wire:confirm` are admin settings (remove logo, clear system
|
||||
password) and admin dashboard (delete share). `AdminDashboard::headers()` exists only for
|
||||
maryUI's table.
|
||||
- Tests assert text only, never markup; no browser tests.
|
||||
- Docker: the image's caches run in `docker/entrypoint.sh` (`config:cache`, `route:cache`,
|
||||
`view:cache`); `docker/dev-entrypoint.sh` runs `npm run build` against the host's mounted
|
||||
`vendor/` without a `composer install`. The Flux credentials step is in `tests.yml`,
|
||||
`docker.yml` **and** `lint.yml`.
|
||||
- Screens: setup, system password, upload, share created, share download, admin dashboard,
|
||||
admin settings, settings (profile, password, appearance, two-factor), Fortify pages (login,
|
||||
forgot, reset, 2FA challenge, confirm, verify email). Stock Laravel error pages and mails.
|
||||
|
||||
**Constraints found.**
|
||||
|
||||
- SealShare's `Dockerfile` builds assets (stage 1) **before** `composer install` (stage 2);
|
||||
CSS imported from `vendor/` needs the order swapped.
|
||||
- Laravel **replaces** the `errors` view namespace at render time with
|
||||
`config('view.paths')` + `/errors` and the framework's own
|
||||
(`Illuminate/Foundation/Exceptions/RegisterErrorViewPaths.php`), so error views a package
|
||||
adds with `addNamespace('errors', …)` are wiped; only a path in `view.paths` survives.
|
||||
- The package lives at `https://gitea.nonameweb.ch/noNameWEB/livewire-material.git` (public,
|
||||
anonymous reads verified 2026-09-13).
|
||||
|
||||
## Decisions
|
||||
|
||||
The package's decisions are in its own plan. SealShare's:
|
||||
|
||||
- **Converts after `1.0.0`, in one pass, by hand** (~150 tags; no codemod), on branch
|
||||
`material`, released as **2.0.0**.
|
||||
- **Moving SealShare to Gitea is a separate plan** — this plan works wherever it is hosted.
|
||||
- **Seed `#4f46e5` (the favicon's indigo), Vibrant** — chosen after comparing it with Tonal Spot
|
||||
on the upload page in both themes (2026-09-13): Tonal Spot read grey-lavender on this seed.
|
||||
- **Theme default `system`**, storage key `sealshare-theme`, legacy `mary-theme` adopted once.
|
||||
Appearance is a Light / Dark / System connected button group.
|
||||
- **One top app bar everywhere** — logo and site title; a theme toggle for guests, an avatar
|
||||
account menu (Upload, Admin dashboard, Admin settings, Settings, theme, Log out) for users;
|
||||
centered content; Admin and Settings sub-pages as secondary tabs (menu picker on a phone);
|
||||
auth pages a centered card under the same bar. No rail, no bottom bar.
|
||||
- **Expressive components plus two hero moments** — an Expressive shape behind the upload icon
|
||||
that morphs while files are dragged over, the wavy progress indicator for uploads, a
|
||||
shape-backed check when the link is ready; admin stats count up once. Instant under
|
||||
`prefers-reduced-motion`.
|
||||
- **The public download page uses no anchored components** (no menus, no tooltips) — it must
|
||||
work for recipients on iOS below 18.4.
|
||||
- **Starter-kit cleanup during the conversion** — delete the placeholder `/dashboard`, point
|
||||
Fortify `home` at the admin dashboard, delete the unused views and the `registerView`
|
||||
binding, drop `alpinejs` from npm and the Flux step from CI.
|
||||
- **Confirmations become M3 basic dialogs** (the 3 `wire:confirm`) — the browser's native
|
||||
confirm cannot be themed and reads as a different app. *(Not asked in the interview; object
|
||||
in review if you prefer the native confirm.)*
|
||||
- **Save feedback becomes a snackbar** through the package's `Toasts` concern — admin
|
||||
settings' flashed `session('message')` alert and settings' "Saved." `action-message` alike.
|
||||
*(Follows from the snackbar; not asked separately.)*
|
||||
- **The font is self-hosted** — the fonts.bunny.net request goes, which also suits a
|
||||
privacy-minded self-hosted app.
|
||||
- **Tests: updated feature tests, the package's guard as `DesignLanguageTest`, Livewire tests
|
||||
for changed behaviour, and four browser tests** with `pestphp/pest-plugin-browser` (new dev
|
||||
dependency, approved).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- ReStride adopting the package — its own plan, after `1.0.0`.
|
||||
- Moving SealShare's repository, CI and image registry to Gitea — its own plan.
|
||||
- Everything the package plan puts out of scope.
|
||||
- Changes to SealShare's features, routes or information architecture beyond the cleanup above.
|
||||
|
||||
## Implementation steps
|
||||
|
||||
Step numbers continue the original plan's, so references elsewhere stay valid.
|
||||
|
||||
### Phase 11 — SealShare 2.0.0 (after `1.0.0`)
|
||||
|
||||
34. **Branch** `material` from `main`; open the PR so CI runs.
|
||||
35. **Dependencies.** Add the `vcs` repository and `composer require nonameweb/livewire-material:^1.0`;
|
||||
`composer remove robsontenorio/mary` (drops `blade-heroicons` with it);
|
||||
`npm remove daisyui alpinejs`; `composer require --dev pestphp/pest-plugin-browser`.
|
||||
maryUI goes **first** because its class components would shadow the package's same-named
|
||||
anonymous ones; the branch is therefore red from here until step 44, which is accepted —
|
||||
it merges once, green (Decisions: one pass).
|
||||
36. **CI and Docker.** Remove the Flux credentials step from `.github/workflows/tests.yml`,
|
||||
`docker.yml` and `lint.yml`; install Playwright browsers in `tests.yml`. `Dockerfile`: run
|
||||
the Composer stage first and `COPY --from=vendor /app/vendor ./vendor` into the Node stage
|
||||
before `npm run build`. `docker/dev-entrypoint.sh`: run `composer install` when `vendor/` is
|
||||
missing, before `npm run build`. No `icons:cache` anywhere: the package draws its symbols
|
||||
without blade-icons.
|
||||
37. **Styles and scheme.** `resources/css/app.css`: `@import 'tailwindcss'`, the package entry
|
||||
from `vendor/`, `./material-scheme.css`, `@source '../views'` and the package's views; drop
|
||||
the daisyUI plugin, maryUI and pagination `@source`s and the swap safelist.
|
||||
`resources/js/app.js` imports the package JS. Run
|
||||
`php artisan material:scheme "#4f46e5" --variant=vibrant` (chosen over Tonal Spot after
|
||||
comparing both on the upload page in both themes).
|
||||
38. **Head and theme.** `partials/head`: remove fonts.bunny.net; include `<x-theme-script />`
|
||||
before `@vite` (it currently sits outside `<head>`). Publish the config with
|
||||
`theme.default = system`, `storage_key = sealshare-theme`, `legacy_keys = ['mary-theme']`.
|
||||
39. **Layouts.** Rebuild `layouts/app.blade.php` (absorbing `app/sidebar`): `<x-app-bar>` with
|
||||
`app-logo-icon` / branding logo and site title, `<x-theme-toggle>` for guests or
|
||||
`<x-account-menu>` for users (Upload, Admin dashboard, Admin settings, Settings, theme, Log
|
||||
out through `App\Livewire\Actions\Logout`), centered content, `<x-toast>`.
|
||||
`layouts/auth.blade.php` (absorbing `auth/simple`): the same bar and a centered card.
|
||||
40. **Cleanup.** Delete the `/dashboard` route, `dashboard.blade.php`, `placeholder-pattern`,
|
||||
`welcome`, `pages/auth/register` and its `Fortify::registerView` line,
|
||||
`layouts/app/{header,sidebar}`, `layouts/auth/{card,split,simple}`, `app-logo`,
|
||||
`desktop-user-menu`. Fortify `home` → `/admin/dashboard`. Update `AuthenticationTest:22`
|
||||
and `EmailVerificationTest:32,63` to the new redirect; `DashboardTest` is rewritten to
|
||||
assert that a signed-in admin lands on the admin dashboard and `/dashboard` is gone
|
||||
(replacing its placeholder tests, approved in the interview). `RegistrationTest` stays.
|
||||
41. **Public pages.** `livewire/file-uploader`: drop zone with `<x-shape>` behind the upload
|
||||
icon morphing while `dragging`, existing Alpine folder walking and `livewire-upload-*`
|
||||
wiring kept, wavy `<x-progress>`, `<x-loading>` for processing, selected files as
|
||||
`<x-list>`, Share Options `<x-card>` (`<x-toggle>`, `<x-select>`, number `<x-input>`s),
|
||||
`<x-alert>` for storage full, filled primary "Create Share Link".
|
||||
`share-created`: shape-backed check, `<x-input copyable>` for the link, four `<x-stat>`,
|
||||
info `<x-alert>`, "Upload More". `share-download`: password `<x-card>` with
|
||||
`<x-password>`, files as `<x-list>` with download icon buttons, "Download All" — no menus
|
||||
or tooltips. `system-password-prompt`, `setup-wizard` onto fields and buttons.
|
||||
42. **Auth pages** (`login`, `forgot-password`, `reset-password`, `two-factor-challenge`,
|
||||
`confirm-password`, `verify-email`): fields, `<x-checkbox>` for remember me, `link` utility
|
||||
for text links, `auth-session-status` onto `<x-alert>` (drops `text-green-600`).
|
||||
43. **Settings.** `pages/settings/layout` → `<x-section-nav>`; `partials/settings-heading`
|
||||
drops the daisyUI divider for `<x-divider>`; `profile` and `password` show "Saved." as a
|
||||
snackbar through `Toasts` (the `profile-updated` / `password-updated` dispatches stay for
|
||||
any listener) and `components/action-message` is deleted;
|
||||
`appearance` → Light / Dark / System `<x-group>` on `$store.theme` (the only toggle on the
|
||||
page); `two-factor` → `<x-badge>` status, `<x-modal fullscreen>` setup with the QR on a
|
||||
white token surface, `<x-input copyable>` key, recovery codes; `delete-user-form` →
|
||||
`<x-modal>` with a `danger` action.
|
||||
44. **Admin.** `admin-dashboard`: four `<x-stat>` (counting up once), disk usage
|
||||
`<x-progress>`, hand-written `<x-table>` with `<x-sort-header>` and pagination (the
|
||||
`@scope` cells become plain Blade and `AdminDashboard::headers()` goes), view and delete
|
||||
icon buttons, delete confirmation in a basic `<x-modal>` instead of `wire:confirm`.
|
||||
`admin-settings`: cards, `<x-textarea>`, `<x-file>` for the logo with preview,
|
||||
`<x-toggle>`, `<x-select>`, `<x-input suffix>`; "Remove the logo?" and "Remove the system
|
||||
password?" become basic dialogs instead of `wire:confirm`; `AdminSettings` uses `Toasts`
|
||||
instead of `session()->flash('message')` (3 places) and the alert block goes. Keep every
|
||||
existing `data-test` attribute on the element that now plays its role.
|
||||
45. **Error pages and mail.** Confirm the package's error views render in SealShare's theme;
|
||||
set `config/mail.php` `markdown.theme` to `livewire-material::mail.theme`; check the
|
||||
password-reset and verify-email mails.
|
||||
46. **Guards.** `tests/Feature/DesignLanguageTest.php` using `DesignGuard` over
|
||||
`resources/views` and `app/` — no maryUI, no daisyUI, only declared colours, only existing
|
||||
icons. A grep for `base-content|bg-base|btn|mary` returns nothing.
|
||||
47. **Rules and AI.** `php artisan boost:update --discover` to install the package guideline and
|
||||
skill; `record-rule` for SealShare: the scheme is regenerated with `material:scheme`,
|
||||
never hand-edited; the download page stays free of anchored components; the theme key.
|
||||
48. **Docs.** README tech stack and the "Dark Mode" feature line; CHANGELOG `2.0.0`.
|
||||
49. **Ship.** Full suite green on the PR; merge; tag `v2.0.0` (publishes the image through
|
||||
`docker.yml`).
|
||||
|
||||
## Testing
|
||||
|
||||
- Feature tests updated where redirects or text change: `AuthenticationTest`,
|
||||
`EmailVerificationTest`, `DashboardTest` (rewritten), `AdminSettingsTest` (asserts the
|
||||
toast is dispatched instead of the flash), `TwoFactorAuthenticationTest`,
|
||||
`AdminDashboardTest`, `ShareDownloadTest`.
|
||||
- `DesignLanguageTest` through the package guard.
|
||||
- Livewire tests: admin settings save/remove-logo/clear-password dispatch toasts; profile and
|
||||
password updates dispatch the "Saved." toast (`ProfileUpdateTest`, `PasswordUpdateTest`);
|
||||
delete share, remove logo and clear system password go through their dialogs' confirm
|
||||
actions.
|
||||
- Browser tests (`tests/Browser`): upload by drop and by Browse → progress → share created →
|
||||
copy link; the password-protected download page at 393px; admin table sort and delete
|
||||
dialog; a first visit follows the OS theme and Appearance switches it.
|
||||
- Narrow runs per step; the full suite on the PR's CI.
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
- **Scope and time.** The whole catalogue (~45 components plus extras) comes before SealShare
|
||||
changes at all, so its starter-kit bugs (the placeholder `/dashboard`) stay until then.
|
||||
Mitigation: waves tagged `0.x`, each reviewed in the showcase; SealShare keeps working
|
||||
meanwhile.
|
||||
- **Gitea becomes a build dependency.** Every SealShare CI run and Docker build fetches the
|
||||
package from `gitea.nonameweb.ch`; an outage or a sign-in setting reverting breaks builds.
|
||||
Mitigation: dist archives cached by Composer in CI; revisit Packagist if it bites.
|
||||
- **iOS / Safari below 18.4.** Anchored menus and tooltips do not position there. Mitigation:
|
||||
SealShare's download page uses none; native `<select>` stays the fallback everywhere.
|
||||
- **Scheme and spring values are tuned by eye**; Tonal Spot may read washed out on indigo —
|
||||
the Vibrant comparison in step 37 is the check.
|
||||
@@ -1,149 +0,0 @@
|
||||
# Share by QR code and share sheet
|
||||
|
||||
## Goal
|
||||
|
||||
After an upload, the share created page offers two more ways to hand a share over besides
|
||||
copying the link: a QR code, in a dialog, that another device scans (and that downloads as a
|
||||
PNG for chats and mails), and, where the browser has one, the device's native share sheet. Both
|
||||
carry only the share's link — never a password.
|
||||
|
||||
## Context
|
||||
|
||||
- Laravel 13.31, Livewire 4.4, Livewire Material 1.0.x, Pest 5 with browser tests, Octane
|
||||
(FrankenPHP). Production image `dunglas/frankenphp:php8.5-alpine` with `intl`, `pcntl`, `zip`
|
||||
added; it has `xmlwriter` and `iconv`, and neither `gd` nor `imagick`.
|
||||
- `bacon/bacon-qr-code` v3.1.1 is installed through `laravel/fortify` (`^3.0`), which draws the
|
||||
two-factor setup QR with `Writer` + `ImageRenderer` + `SvgImageBackEnd` and strips the XML
|
||||
declaration (`TwoFactorAuthenticatable::twoFactorQrCodeSvg()`). SVG needs no image extension;
|
||||
a server-side PNG would.
|
||||
- `app/Livewire/ShareCreated.php` (`#[Layout('layouts.app')]`, `public Share $share`) renders
|
||||
`resources/views/livewire/share-created.blade.php`: the link as
|
||||
`<x-input :value="route('share.download', $share)" readonly copyable data-test="share-link">`,
|
||||
four `<x-stat>`, an info alert for password-protected shares, and "Upload More". The route
|
||||
`share/{share:token}/created` sits behind `system.password`, like the upload page.
|
||||
- The two-factor dialog (`pages/settings/⚡two-factor`) is the in-app pattern: the SVG inline
|
||||
on a `bg-white` panel inside `<x-modal fullscreen>`, so it stays scannable in dark mode.
|
||||
- `<x-modal>` without `wire:model` opens from `open` in the surrounding Alpine scope and gives
|
||||
`close()`; `materialToast()` is global; `resources/js/app.js` imports only the package JS.
|
||||
- Services live in `app/Services` (`ShareService`, `FileEncryptionService`). Share passwords
|
||||
are hashed and turned into a key; the plain password is never stored.
|
||||
- No feature test covers `ShareCreated` yet; `tests/Browser/SealShareTest.php` copies the link
|
||||
on that page.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Only on the share created page** — that is where a share is handed over; the admin
|
||||
dashboard and the download page stay as they are.
|
||||
- **A "Show QR code" button opens a dialog** — the page stays as calm as now; the dialog is
|
||||
`<x-modal fullscreen>` (the whole screen on a phone, to hold up to another camera) with the
|
||||
QR on a white panel.
|
||||
- **Download is a PNG made in the browser** — the dialog's SVG is drawn onto a canvas and saved
|
||||
as `share-<token>.png`; no server route and no `gd`/`imagick` in the Docker images.
|
||||
- **Require `bacon/bacon-qr-code:^3.0` directly** — the version already installed through
|
||||
Fortify, declared so SealShare does not depend on Fortify keeping it.
|
||||
- **Password-protected shares get a note in the dialog** — "Recipients also need the password."
|
||||
The QR holds the link only.
|
||||
- **A "Share…" button opens the native share sheet** — shown only where `navigator.share` exists
|
||||
(mostly phones and Safari), sharing `{ title: <site title>, url: <share link> }`.
|
||||
Cancelling the sheet (`AbortError`) does nothing; any other failure shows an error snackbar.
|
||||
- **In 2.0.0, on the `material` branch** — 2.0.0 is not released and the page was just rebuilt
|
||||
there; one PR, one changelog entry.
|
||||
- **Black modules on white, a four-module quiet zone, error correction M** — the most reliable
|
||||
to scan from a screen or a print; the site's theme does not tint it.
|
||||
- **The SVG is drawn at 1024 × 1024** — CSS scales it down in the dialog, and the canvas draws it
|
||||
at its own size, so the PNG is sharp in every browser (Safari rasterises an SVG at its
|
||||
intrinsic size).
|
||||
- **Generated server-side with the page, opened client-side** — the SVG is a few kilobytes and
|
||||
the dialog needs no round trip; the dialog's `open` is Alpine state, not a Livewire property.
|
||||
- **`App\Services\QrCodeService::svg(string $contents): string`** — one place that knows Bacon's
|
||||
API; `ShareCreated::render()` passes `shareUrl`, `qrCodeSvg` and `siteTitle` to the view (as
|
||||
`FileUploader::render()` passes `siteTitle`), so the URL is built once.
|
||||
- **The share and download behaviour lives in `resources/js/share-created.js`** — an
|
||||
`Alpine.data('shareActions', …)` with `canShare`, `share()` and `downloadQrCode()`, imported
|
||||
by `app.js`, instead of long inline Alpine in the view.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- QR codes on the admin dashboard or the download page.
|
||||
- Sharing the QR image itself through the share sheet (`navigator.share({ files })`).
|
||||
- An SVG download, a server-rendered PNG, or a print layout.
|
||||
- A logo in the middle of the QR, or colours from the theme.
|
||||
- Putting the password (or any secret beyond the link's token) into the QR or the share sheet.
|
||||
|
||||
## Implementation steps
|
||||
|
||||
1. **Dependency.** `composer require bacon/bacon-qr-code:^3.0` (stays at v3.1.1).
|
||||
2. **Service.** `php artisan make:class Services/QrCodeService`: `svg(string $contents): string`
|
||||
renders with `new Writer(new ImageRenderer(new RendererStyle(1024, 4, null, null,
|
||||
Fill::uniformColor(new Rgb(255, 255, 255), new Rgb(0, 0, 0))), new SvgImageBackEnd))`,
|
||||
`writeString($contents, Encoder::DEFAULT_BYTE_MODE_ENCODING, ErrorCorrectionLevel::M())`, and
|
||||
drops the XML declaration as Fortify does.
|
||||
3. **Component.** `ShareCreated::render()` builds `$shareUrl = route('share.download',
|
||||
$this->share)` and passes `shareUrl`, `qrCodeSvg` (from the service) and `siteTitle`
|
||||
(`Setting::get('site_title') ?: config('app.name')`) to the view; the link field uses
|
||||
`$shareUrl`.
|
||||
4. **JavaScript.** `resources/js/share-created.js` registers on `alpine:init`
|
||||
`Alpine.data('shareActions', ({ url, title, filename, messages }) => …)`, `messages` holding
|
||||
the translated `shareFailed` and `downloadFailed`:
|
||||
- `open: false` for the dialog;
|
||||
- `canShare`: `typeof navigator.share === 'function'`, read once at init;
|
||||
- `share()`: `navigator.share({ title, url })`, ignoring `AbortError`, otherwise
|
||||
`materialToast(messages.shareFailed, { type: 'error' })`;
|
||||
- `downloadQrCode(svg)`: takes the `<svg>` element (the button passes
|
||||
`$el.closest('dialog').querySelector('[data-qr-code] svg')` — the dialog has its own Alpine
|
||||
scope, so `$refs` from the outer one would not reach it), serialises it, loads it into an
|
||||
`Image` from a Blob URL, draws it on a 1024 × 1024 canvas with a white fill and
|
||||
`imageSmoothingEnabled = false`, `toBlob('image/png')`, clicks a temporary `<a download>`
|
||||
named `filename`, and revokes both object URLs; a failed load or an empty blob shows
|
||||
`materialToast(messages.downloadFailed, { type: 'error' })`.
|
||||
`resources/js/app.js` imports it after the package.
|
||||
5. **View.** In `share-created.blade.php`, wrap the link and actions in
|
||||
`<div x-data="shareActions({ url: @js($shareUrl), title: @js($siteTitle), filename: @js('share-'.$share->token.'.png'), messages: @js(['shareFailed' => __('The share sheet could not open.'), 'downloadFailed' => __('The QR code could not be saved.')]) })">`
|
||||
(a plain element, so `@js` compiles there):
|
||||
- under the link field, a row with `<x-button :label="__('Show QR code')" icon="qr_code_2"
|
||||
variant="tonal" x-on:click="open = true" data-test="show-qr-code" />` and, in a
|
||||
`<span x-show="canShare" x-cloak>` wrapper, `<x-button :label="__('Share…')" icon="share"
|
||||
variant="tonal" x-on:click="share()" data-test="share-sheet" />`;
|
||||
- `<x-modal fullscreen :title="__('Scan to open the share')">` holding
|
||||
`<div data-qr-code class="mx-auto aspect-square w-full max-w-80 rounded-corner-lg bg-white p-2 [&>svg]:size-full">{!! $qrCodeSvg !!}</div>`
|
||||
(the SVG is generated from the app's own URL — no user input), then, for a
|
||||
password-protected share, `<x-alert color="info" icon="lock" :title="__('Recipients also need the password.')" />`,
|
||||
and actions `<x-button :label="__('Download')" icon="download" x-on:click="downloadQrCode($el.closest('dialog').querySelector('[data-qr-code] svg'))" data-test="download-qr-code" />`
|
||||
and `<x-button :label="__('Close')" x-on:click="close()" />`.
|
||||
"Upload More" and the stats stay where they are.
|
||||
6. **Docs.** README: the "Shareable Links" feature line mentions the QR code and share sheet.
|
||||
CHANGELOG `2.0.0`: an "Added" section (before "Changed", as Keep a Changelog orders them)
|
||||
with an entry for both.
|
||||
|
||||
## Testing
|
||||
|
||||
- `tests/Unit/QrCodeServiceTest.php` (the service needs no application):
|
||||
`svg()` returns markup starting with `<svg`, without an XML declaration, 1024 wide, and the
|
||||
same markup for the same contents and different markup for different contents.
|
||||
- `tests/Feature/ShareCreatedTest.php` (new): the page shows the link, and its HTML contains
|
||||
exactly `QrCodeService::svg(route('share.download', $share))` inside the dialog, the
|
||||
"Show QR code" and "Share…" buttons, and the download filename `share-<token>.png`; the
|
||||
password note appears for a protected share and not for an open one.
|
||||
- `tests/Browser/SealShareTest.php`:
|
||||
- "Show QR code" opens the dialog with the QR on a white panel; Download produces an
|
||||
`image/png` blob named `share-<token>.png` (recorded by stubbing
|
||||
`HTMLAnchorElement.prototype.click` through `window.eval`), with no JavaScript errors;
|
||||
- the Share button is hidden where `navigator.share` is missing, and `share()` passes the
|
||||
link to a stubbed `navigator.share`, stays quiet on `AbortError` and shows the error snackbar
|
||||
on any other rejection.
|
||||
- `DesignLanguageTest` keeps passing (`qr_code_2`, `share`, `download` are Material Symbols;
|
||||
`bg-white` is a token).
|
||||
- Narrow runs per step, then the full suite.
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
- **Scanning reliability** is not proven by the tests (no decoder in the stack): the feature
|
||||
test pins the SVG to Bacon's output for the exact URL, and a manual scan with a phone during
|
||||
review is the check.
|
||||
- **The QR is only as right as the link.** Behind a reverse proxy with a wrong `APP_URL` or
|
||||
trusted-proxy setting, both point at the wrong host — unchanged from today.
|
||||
- **Safari and canvas.** Drawing an SVG from a Blob URL onto a canvas works in current
|
||||
Chrome, Firefox and Safari without tainting the canvas; the browser test runs in Chromium
|
||||
locally and in CI, and the three-engine check is manual.
|
||||
- **Share sheet on desktop** exists in Safari and Chromium on some platforms and not in
|
||||
Firefox; the button's absence there is by design.
|
||||
@@ -2,6 +2,10 @@
|
||||
<h1 class="mb-6 type-headline-md">{{ __('System Settings') }}</h1>
|
||||
|
||||
<form wire:submit="saveSettings" class="grid gap-6">
|
||||
<x-card :title="__('Colour profile')" variant="outlined">
|
||||
<x-scheme-picker wire:model="colorProfile" :hint="__('Choosing one previews it here. After saving, every page, mail and error page uses it.')" data-test="color-profile" />
|
||||
</x-card>
|
||||
|
||||
<x-card :title="__('Branding')" variant="outlined">
|
||||
<div class="grid gap-5">
|
||||
<x-input wire:model="siteTitle" :label="__('Site Title')" :hint="__('Displayed as the heading on the upload page.')" />
|
||||
@@ -99,7 +103,7 @@
|
||||
/>
|
||||
</x-card>
|
||||
|
||||
<x-button type="submit" :label="__('Save Settings')" variant="filled" icon="check" spinner="saveSettings" class="w-full" />
|
||||
<x-button type="submit" :label="__('Save Settings')" variant="filled" icon="check" spinner="saveSettings" class="w-full" data-test="save-settings" />
|
||||
</form>
|
||||
|
||||
<x-modal wire:model="confirmingLogoRemoval" :title="__('Remove the logo?')" icon="delete">
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\Share;
|
||||
use App\Models\User;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
|
||||
/**
|
||||
* A page of SealShare, once it can be used: loaded, with Alpine and Livewire started.
|
||||
@@ -152,3 +154,25 @@ test('a first visit follows the system theme, and Appearance switches it', funct
|
||||
->assertScript("document.documentElement.dataset.theme === 'light'")
|
||||
->assertScript("localStorage.getItem('sealshare-theme') === 'light'");
|
||||
});
|
||||
|
||||
test('an admin previews a colour profile, saves it, and every page wears it', function () {
|
||||
$this->actingAs(User::factory()->admin()->create());
|
||||
|
||||
$page = ready(visit('/admin/settings'));
|
||||
|
||||
$page->assertScript("document.documentElement.getAttribute('data-scheme') === 'indigo'")
|
||||
->click('[data-test="color-profile"] [data-scheme-option="teal"]')
|
||||
->assertScript("document.documentElement.getAttribute('data-scheme') === 'teal'");
|
||||
|
||||
expect(Setting::get('color_profile'))->toBeNull();
|
||||
|
||||
$page->click('[data-test="save-settings"]')
|
||||
->assertSee('Settings saved successfully.');
|
||||
|
||||
expect(Setting::get('color_profile'))->toBe('teal');
|
||||
|
||||
ready(visit('/upload'))
|
||||
->assertScript("document.documentElement.getAttribute('data-scheme') === 'teal'")
|
||||
->assertScript("getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-primary').trim() === '".Scheme::profiles()['teal']['light']['primary']."'")
|
||||
->assertNoJavaScriptErrors();
|
||||
});
|
||||
|
||||
@@ -128,3 +128,32 @@ test('admin can remove the logo through its dialog', function () {
|
||||
expect(Setting::get('site_logo'))->toBeNull();
|
||||
Storage::disk('public')->assertMissing($path);
|
||||
});
|
||||
|
||||
test('admin chooses the colour profile, starting from the saved one', function () {
|
||||
$admin = User::query()->where('is_admin', true)->first();
|
||||
|
||||
Livewire::actingAs($admin)
|
||||
->test(AdminSettings::class)
|
||||
->assertSet('colorProfile', 'indigo')
|
||||
->set('colorProfile', 'teal')
|
||||
->call('saveSettings')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(Setting::get('color_profile'))->toBe('teal');
|
||||
|
||||
Livewire::actingAs($admin)
|
||||
->test(AdminSettings::class)
|
||||
->assertSet('colorProfile', 'teal');
|
||||
});
|
||||
|
||||
test('a colour profile that was not generated is refused', function () {
|
||||
$admin = User::query()->where('is_admin', true)->first();
|
||||
|
||||
Livewire::actingAs($admin)
|
||||
->test(AdminSettings::class)
|
||||
->set('colorProfile', 'ocean')
|
||||
->call('saveSettings')
|
||||
->assertHasErrors(['colorProfile' => 'in']);
|
||||
|
||||
expect(Setting::get('color_profile'))->toBeNull();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Notifications\ResetPassword;
|
||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
|
||||
test('every page wears the colour profile the admin saved, indigo until then', function () {
|
||||
$this->get(route('upload'))->assertOk()->assertSee('({"scheme":"indigo",', false);
|
||||
|
||||
Setting::set('color_profile', 'graphite');
|
||||
|
||||
$this->get(route('upload'))->assertSee('({"scheme":"graphite",', false);
|
||||
$this->get('/s/does-not-exist')->assertNotFound()->assertSee('({"scheme":"graphite",', false);
|
||||
});
|
||||
|
||||
test('a saved profile that no longer exists falls back to the default', function () {
|
||||
Setting::set('color_profile', 'ocean');
|
||||
|
||||
expect(Scheme::profile())->toBe('indigo');
|
||||
$this->get(route('upload'))->assertSee('({"scheme":"indigo",', false);
|
||||
});
|
||||
|
||||
test('mails take the saved colour profile', function () {
|
||||
Setting::set('color_profile', 'rose');
|
||||
|
||||
$html = (string) (new ResetPassword('token'))->toMail(User::factory()->create())->render();
|
||||
|
||||
expect($html)->toContain('background-color: '.Scheme::profiles()['rose']['light']['primary'])
|
||||
->not->toContain('background-color: '.Scheme::profiles()['indigo']['light']['primary']);
|
||||
});
|
||||
|
||||
test('the stylesheet carries all eight profiles', function () {
|
||||
expect(array_keys(Scheme::profiles()))->toBe(['indigo', 'blue', 'teal', 'green', 'amber', 'rose', 'violet', 'graphite'])
|
||||
->and(file_get_contents(resource_path('css/material-scheme.css')))->toContain("[data-scheme='graphite'][data-theme='dark']");
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* SealShare lives on Gitea only: the source at gitea.nonameweb.ch/noNameWEB/SealShare and the image
|
||||
* at gitea.nonameweb.ch/nonameweb/sealshare. The changelog keeps its history, and the website's
|
||||
* comparison sources link other projects' repositories, so neither is checked.
|
||||
*/
|
||||
|
||||
test('the project points to Gitea, not GitHub, for its source, image and workflows', function () {
|
||||
$files = [
|
||||
'README.md',
|
||||
'Dockerfile',
|
||||
'.dockerignore',
|
||||
'.gitattributes',
|
||||
'docker-compose.yml',
|
||||
'docker-compose.example.yml',
|
||||
...array_map(fn (string $path): string => str($path)->after(base_path().'/')->toString(), glob(base_path('.gitea/workflows/*.yml'))),
|
||||
];
|
||||
|
||||
foreach ($files as $file) {
|
||||
expect(file_get_contents(base_path($file)))->not->toMatch('/github|ghcr\.io/i', "{$file} still refers to GitHub");
|
||||
}
|
||||
|
||||
expect(base_path('.github'))->not->toBeDirectory()
|
||||
->and(glob(base_path('.gitea/workflows/*.yml')))->not->toBeEmpty();
|
||||
});
|
||||
|
||||
test('the website installs SealShare from Gitea', function () {
|
||||
preg_match('/<section[^>]*id="install".*?<\/section>/s', file_get_contents(base_path('website/index.html')), $install);
|
||||
|
||||
expect($install[0])->not->toMatch('/github|ghcr\.io/i')
|
||||
->toContain('gitea.nonameweb.ch/nonameweb/sealshare:latest')
|
||||
->toContain('https://gitea.nonameweb.ch/noNameWEB/SealShare/raw/branch/main/docker-compose.example.yml');
|
||||
});
|
||||
|
||||
test('images are published to the Gitea registry and releases are made on Gitea', function () {
|
||||
$workflow = file_get_contents(base_path('.gitea/workflows/docker.yml'));
|
||||
|
||||
expect($workflow)
|
||||
->toContain('registry: gitea.nonameweb.ch')
|
||||
->toContain('images: gitea.nonameweb.ch/nonameweb/sealshare')
|
||||
->toContain('password: ${{ secrets.REGISTRY_TOKEN }}')
|
||||
->toContain('uses: https://gitea.com/actions/gitea-release-action@v1');
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/*
|
||||
* docker/entrypoint.sh caches the configuration, routes and views on every start, with the
|
||||
* Livewire Material showcase off (it only runs locally). A view that does not compile there stops
|
||||
* the container from booting, so the tests compile them as production does.
|
||||
*/
|
||||
|
||||
test('every view compiles as the production entrypoint caches them', function () {
|
||||
expect(config('livewire-material.showcase.enabled'))->toBeFalse();
|
||||
|
||||
$compiled = storage_path('framework/testing/view-cache-'.uniqid());
|
||||
File::ensureDirectoryExists($compiled);
|
||||
config(['view.compiled' => $compiled]);
|
||||
|
||||
try {
|
||||
$this->artisan('view:cache')->assertSuccessful();
|
||||
} finally {
|
||||
File::deleteDirectory($compiled);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* website/ is uploaded by hand as it is, so these guard what a broken upload would show: files that
|
||||
* are not there, requests to other hosts, and claims the application does not keep.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The site's HTML pages, by name.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
function websitePages(): array
|
||||
{
|
||||
return [
|
||||
'index.html' => file_get_contents(base_path('website/index.html')),
|
||||
'privacy.html' => file_get_contents(base_path('website/privacy.html')),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every URL an HTML document refers to in `src`, `href`, `srcset` and the gallery's `data-*-src` and
|
||||
* `data-*-srcset`, with each srcset entry on its own.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
function websiteReferences(string $html): array
|
||||
{
|
||||
preg_match_all('/\s(src|href|srcset|data-[a-z]+-src|data-[a-z]+-srcset)="([^"]*)"/', $html, $matches, PREG_SET_ORDER);
|
||||
|
||||
return collect($matches)
|
||||
->flatMap(fn (array $match): array => str_ends_with($match[1], 'srcset')
|
||||
? array_map(fn (string $candidate): string => strtok(trim($candidate), ' '), explode(',', $match[2]))
|
||||
: [$match[2]])
|
||||
->map(fn (string $url): string => html_entity_decode($url))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
test('every file the pages, their stylesheets and the README refer to exists', function () {
|
||||
$missing = [];
|
||||
|
||||
foreach (websitePages() as $page => $html) {
|
||||
foreach (websiteReferences($html) as $url) {
|
||||
if (preg_match('/^(#|https?:|mailto:)/', $url) === 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! is_file(base_path('website/'.strtok($url, '#?')))) {
|
||||
$missing[] = "{$page}: {$url}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (glob(base_path('website/css/*.css')) as $stylesheet) {
|
||||
preg_match_all('/url\([\'"]?([^\'")]+)[\'"]?\)/', file_get_contents($stylesheet), $matches);
|
||||
|
||||
foreach ($matches[1] as $url) {
|
||||
if (! str_starts_with($url, 'data:') && ! is_file(dirname($stylesheet).'/'.strtok($url, '#?'))) {
|
||||
$missing[] = basename($stylesheet).": {$url}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (websiteReferences(file_get_contents(base_path('README.md'))) as $url) {
|
||||
if (str_starts_with($url, 'website/') && ! is_file(base_path($url))) {
|
||||
$missing[] = "README.md: {$url}";
|
||||
}
|
||||
}
|
||||
|
||||
expect($missing)->toBe([]);
|
||||
});
|
||||
|
||||
test('the screenshots are all there, for both themes and both widths', function () {
|
||||
$expected = collect([
|
||||
'desktop' => ['01-upload', '02-share-created', '03-qr-code', '04-download', '05-admin-dashboard', '06-admin-settings'],
|
||||
'phone' => ['01-upload', '02-password', '03-download', '04-qr-code'],
|
||||
])->flatMap(fn (array $names, string $device): array => collect(['light', 'dark'])
|
||||
->crossJoin($names, $device === 'desktop' ? [1600, 800] : [1080, 540])
|
||||
->map(fn (array $shot): string => "{$device}/{$shot[0]}/{$shot[1]}-{$shot[2]}.webp")
|
||||
->all())
|
||||
->sort()->values()->all();
|
||||
|
||||
$published = collect(glob(base_path('website/img/screenshots/*/*/*.webp')))
|
||||
->map(fn (string $path): string => str($path)->after('website/img/screenshots/')->toString())
|
||||
->sort()->values()->all();
|
||||
|
||||
expect($published)->toBe($expected);
|
||||
});
|
||||
|
||||
test('the pages load nothing from another host but Plausible', function () {
|
||||
foreach (websitePages() as $page => $html) {
|
||||
preg_match_all('/<(?:script|img|source|link)\b[^>]*\s(?:src|srcset|href)="(https?:[^"]*)"/', $html, $matches);
|
||||
|
||||
expect(collect($matches[1])->reject(fn (string $url): bool => str_starts_with($url, 'https://plausible.io/'))->all())
|
||||
->toBe([], "{$page} loads from another host");
|
||||
}
|
||||
|
||||
foreach (glob(base_path('website/css/*.css')) as $stylesheet) {
|
||||
expect(file_get_contents($stylesheet))->not->toMatch('/(url\(\s*[\'"]?https?:|@import)/');
|
||||
}
|
||||
});
|
||||
|
||||
test('the pages are titled, described and in English', function () {
|
||||
foreach (websitePages() as $page => $html) {
|
||||
expect($html)
|
||||
->toContain('<html lang="en"')
|
||||
->toMatch('/<title>[^<]+<\/title>/')
|
||||
->toMatch('/<meta name="description" content="[^"]+"/')
|
||||
->toContain('<script async src="https://plausible.io/js/pa-zWB4R2-rrBeLfwms3uIBT.js"></script>')
|
||||
->toContain('plausible.init()');
|
||||
}
|
||||
});
|
||||
|
||||
test('neither the site nor the README calls the encryption end-to-end', function () {
|
||||
$index = websitePages()['index.html'];
|
||||
|
||||
preg_match('/<section[^>]*id="features".*?<\/section>/s', $index, $features);
|
||||
preg_match_all('/data-compare="sealshare-e2e">([^<]*)</', $index, $cells);
|
||||
|
||||
expect($features[0])->not->toMatch('/end-to-end/i')
|
||||
->and($cells[1])->not->toBeEmpty()->each->toBe('No')
|
||||
->and(file_get_contents(base_path('README.md')))->not->toContain('End-to-End Encryption');
|
||||
});
|
||||
|
||||
test('nothing is left to fill in', function () {
|
||||
foreach ([...websitePages(), 'theme.css' => file_get_contents(base_path('website/css/theme.css'))] as $file => $contents) {
|
||||
expect(str_contains($contents, 'TODO'))->toBeFalse("{$file} still has a TODO");
|
||||
}
|
||||
});
|
||||
@@ -21,7 +21,7 @@ pest()->extend(TestCase::class)
|
||||
// EnsureSetupComplete middleware redirects to /setup unless an admin exists.
|
||||
User::factory()->admin()->create(['email' => 'admin-setup@test.com']);
|
||||
})
|
||||
->in('Feature', 'Browser');
|
||||
->in('Feature', 'Browser', 'Screenshots');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Screenshots;
|
||||
|
||||
use App\Models\Share;
|
||||
use App\Models\User;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* The shares and the admin every screenshot shows. Names, sizes, tokens, counts and dates are fixed
|
||||
* (with the clock frozen by the caller), so a run only produces different images when the interface
|
||||
* changed. Files go through ShareService, so they exist encrypted on the faked `shares` disk.
|
||||
*/
|
||||
final class DemoData
|
||||
{
|
||||
/** The share a customer receives: link, QR code and download page. */
|
||||
public const DELIVERY_TOKEN = 'hG7kP2mXq9LrT4vB';
|
||||
|
||||
/** The password-protected share the phone's password prompt belongs to. */
|
||||
public const PROTECTED_TOKEN = 'Wn3sD8fJ5cYa1ZeQ';
|
||||
|
||||
public const PROTECTED_PASSWORD = 'autumn-harbour-42';
|
||||
|
||||
public static function admin(): User
|
||||
{
|
||||
return User::factory()->admin()->create([
|
||||
'name' => 'Alex Morgan',
|
||||
'email' => 'alex.morgan@example.com',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eight shares of different ages, sizes and states: one expired, two password-protected, some
|
||||
* with a download limit, some never expiring.
|
||||
*/
|
||||
public static function shares(): void
|
||||
{
|
||||
$shares = [
|
||||
['token' => self::DELIVERY_TOKEN, 'daysAgo' => 0, 'expiresAfterDays' => 7, 'downloads' => 3, 'options' => [],
|
||||
'files' => ['Q3 Report.pdf' => 2400, 'Contract 2026.pdf' => 380, 'Product photos/hero-shot.jpg' => 4800, 'Product photos/detail.jpg' => 3900]],
|
||||
['token' => self::PROTECTED_TOKEN, 'daysAgo' => 1, 'expiresAfterDays' => 3, 'downloads' => 1, 'options' => ['password' => self::PROTECTED_PASSWORD, 'max_downloads' => 3],
|
||||
'files' => ['Payroll September.xlsx' => 210, 'Tax statement 2025.pdf' => 640]],
|
||||
['token' => 'Rt5uV6wX7yZ8aB9c', 'daysAgo' => 2, 'expiresAfterDays' => 30, 'downloads' => 7, 'options' => ['max_downloads' => 10],
|
||||
'files' => ['Brand guidelines.pdf' => 8600]],
|
||||
['token' => 'Kc2dE3fG4hJ5kL6m', 'daysAgo' => 5, 'expiresAfterDays' => 14, 'downloads' => 2, 'options' => [],
|
||||
'files' => ['Site plan rev C.dwg' => 12400, 'Site plan rev C.pdf' => 3100]],
|
||||
['token' => 'Np7qR8sT9uV1wX2y', 'daysAgo' => 9, 'expiresAfterDays' => null, 'downloads' => 0, 'options' => ['password' => 'demo-interview'],
|
||||
'files' => ['Interview recording.m4a' => 18300]],
|
||||
['token' => 'Zb4cD5eF6gH7jK8L', 'daysAgo' => 14, 'expiresAfterDays' => 30, 'downloads' => 0, 'options' => ['max_downloads' => 1],
|
||||
'files' => ['Invoice 2026-0412.pdf' => 95]],
|
||||
['token' => 'Mq1rS2tU3vW4xY5z', 'daysAgo' => 21, 'expiresAfterDays' => null, 'downloads' => 4, 'options' => [],
|
||||
'files' => ['Onboarding/Welcome.pdf' => 520, 'Onboarding/Handbook.pdf' => 2900, 'Onboarding/IT checklist.docx' => 130]],
|
||||
['token' => 'Ae6fG7hJ8kL9mN1p', 'daysAgo' => 40, 'expiresAfterDays' => 10, 'downloads' => 12, 'options' => [],
|
||||
'files' => ['Event photos.zip' => 15700]],
|
||||
];
|
||||
|
||||
foreach ($shares as $share) {
|
||||
self::share(...$share);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{password?: string, max_downloads?: int} $options
|
||||
* @param array<string, int> $files relative path => size in kilobytes
|
||||
*/
|
||||
private static function share(string $token, int $daysAgo, ?int $expiresAfterDays, int $downloads, array $options, array $files): void
|
||||
{
|
||||
$createdAt = Carbon::now()->subDays($daysAgo)->subHours(2);
|
||||
|
||||
Str::createRandomStringsUsing(fn (): string => $token);
|
||||
|
||||
try {
|
||||
$share = app(ShareService::class)->createShare(
|
||||
collect($files)->map(fn (int $kilobytes, string $path): array => [
|
||||
'file' => UploadedFile::fake()->create(basename($path), $kilobytes),
|
||||
'relativePath' => str_contains($path, '/') ? $path : null,
|
||||
])->values()->all(),
|
||||
[...$options, 'expires_at' => $expiresAfterDays === null ? null : $createdAt->copy()->addDays($expiresAfterDays)],
|
||||
);
|
||||
} finally {
|
||||
Str::createRandomStringsNormally();
|
||||
}
|
||||
|
||||
$share->forceFill(['download_count' => $downloads, 'created_at' => $createdAt, 'updated_at' => $createdAt])->save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Screenshots;
|
||||
|
||||
use GdImage;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Turns a raw browser capture into the website's images: WebP at each width the site's `srcset`
|
||||
* asks for, under `<device>/<theme>/<name>-<width>.webp`. The capture is read right after it is
|
||||
* taken, because Pest empties its screenshot directory when a browser run starts.
|
||||
*/
|
||||
final class Publisher
|
||||
{
|
||||
public function __construct(
|
||||
private string $capturesPath,
|
||||
private string $publishPath,
|
||||
private int $quality = 82,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The publisher for this project: Pest's captures into `website/img/screenshots`.
|
||||
*/
|
||||
public static function forProject(): self
|
||||
{
|
||||
return new self(base_path('tests/Browser/Screenshots'), base_path('website/img/screenshots'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the capture as WebP at each width, keeping its aspect ratio.
|
||||
*
|
||||
* @param list<int> $widths
|
||||
*/
|
||||
public function publish(string $capture, string $device, string $theme, string $name, array $widths): void
|
||||
{
|
||||
$source = $this->capturesPath.'/'.$capture.'.png';
|
||||
|
||||
if (! is_file($source) || ! ($image = @imagecreatefrompng($source)) instanceof GdImage) {
|
||||
throw new RuntimeException("The capture [{$capture}] was not written, so [{$device}/{$theme}/{$name}] cannot be published.");
|
||||
}
|
||||
|
||||
$directory = $this->publishPath.'/'.$device.'/'.$theme;
|
||||
|
||||
if (! is_dir($directory)) {
|
||||
mkdir($directory, 0755, true);
|
||||
}
|
||||
|
||||
foreach ($widths as $width) {
|
||||
$height = (int) round(imagesy($image) * $width / imagesx($image));
|
||||
$resized = imagecreatetruecolor($width, $height);
|
||||
|
||||
imagecopyresampled($resized, $image, 0, 0, 0, 0, $width, $height, imagesx($image), imagesy($image));
|
||||
imagewebp($resized, $directory.'/'.$name.'-'.$width.'.webp', $this->quality);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
use App\Services\QrCodeService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Features\SupportFileUploads\FileUploadConfiguration;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use Tests\Screenshots\DemoData;
|
||||
use Tests\Screenshots\Publisher;
|
||||
|
||||
/*
|
||||
* The screenshots on the website and in the README, from fixed demo data with the clock frozen.
|
||||
* Not part of any test suite: `composer screenshots` runs this directory (Chromium), and each capture
|
||||
* is published to website/img/screenshots as soon as it is taken.
|
||||
*/
|
||||
|
||||
beforeEach(function () {
|
||||
// Sessions have to outlive a request: a sign-in, an unlocked share.
|
||||
config(['session.driver' => 'file']);
|
||||
Storage::fake('shares');
|
||||
Storage::fake('tmp-for-tests');
|
||||
|
||||
$this->travelTo(Carbon::parse('2026-10-01 09:30'));
|
||||
|
||||
// Livewire deletes temporary uploads a day older than now, by the files' real modification
|
||||
// times: under the frozen clock that is every file this run stores.
|
||||
config(['livewire.temporary_file_upload.cleanup' => false]);
|
||||
|
||||
DemoData::shares();
|
||||
});
|
||||
|
||||
/**
|
||||
* A visited page on a device in a theme, once it can be used. Tests call `visit()` themselves, on a
|
||||
* line of its own: Pest starts its browser only for tests under tests/Browser or whose body calls
|
||||
* `visit(` after whitespace.
|
||||
*/
|
||||
function shotPage(mixed $visit, string $device, string $theme): mixed
|
||||
{
|
||||
$pending = $device === 'desktop' ? $visit->on()->macbook14() : $visit->on()->iPhone15Pro();
|
||||
$page = $theme === 'dark' ? $pending->inDarkMode() : $pending->inLightMode();
|
||||
|
||||
return $page->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the viewport once fonts are in and every finite animation has run, then publish it.
|
||||
*
|
||||
* The in-process server listens on 127.0.0.1 at a random port, so links would read differently on
|
||||
* every run: the page is shown as it reads on an installation at https://files.example.com, the
|
||||
* server's origin replaced in text and fields, and the QR code drawn for that address.
|
||||
*/
|
||||
function shoot(mixed $page, string $device, string $theme, string $name): void
|
||||
{
|
||||
$origin = 'https://files.example.com';
|
||||
$qrCode = app(QrCodeService::class)->svg($origin.'/s/'.DemoData::DELIVERY_TOKEN);
|
||||
|
||||
$page->script("document.head.insertAdjacentHTML('beforeend', '<style>*{caret-color:transparent!important}</style>')");
|
||||
$page->script('(() => { const from = location.origin, to = '.json_encode($origin).'; document.querySelectorAll("input").forEach((input) => { input.value = input.value.replaceAll(from, to) }); const text = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); while (text.nextNode()) { text.currentNode.nodeValue = text.currentNode.nodeValue.replaceAll(from, to) } document.querySelectorAll("[data-qr-code]").forEach((panel) => { panel.innerHTML = '.json_encode($qrCode).' }) })()');
|
||||
|
||||
// Counting figures run on requestAnimationFrame rather than as Web Animations.
|
||||
$page->wait(1.2)
|
||||
->assertScript("document.fonts.status === 'loaded' && document.getAnimations().every((animation) => animation.playState !== 'running' || animation.effect?.getComputedTiming().iterations === Infinity)");
|
||||
|
||||
$capture = "{$device}-{$theme}-{$name}";
|
||||
|
||||
$page->screenshot(fullPage: false, filename: $capture);
|
||||
|
||||
Publisher::forProject()->publish($capture, $device, $theme, $name, $device === 'desktop' ? [1600, 800] : [1080, 540]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Put files into the uploader the way a finished upload does: stored as Livewire temporary uploads
|
||||
* and handed to `_finishUpload` by their signed names. A browser test cannot select files through
|
||||
* the file input, because the in-process server does not store a multipart upload.
|
||||
*
|
||||
* @param array<string, int> $files relative path => size in kilobytes
|
||||
*/
|
||||
function selectFiles(mixed $page, array $files): void
|
||||
{
|
||||
$signed = [];
|
||||
|
||||
foreach ($files as $path => $kilobytes) {
|
||||
// Livewire's own storage for an arriving upload: the file and its name, type and size beside it.
|
||||
$stored = FileUploadConfiguration::storeTemporaryFile(UploadedFile::fake()->create(basename($path), $kilobytes), 'tmp-for-tests');
|
||||
|
||||
$signed[] = TemporaryUploadedFile::signPath(basename($stored));
|
||||
}
|
||||
|
||||
$relativePaths = array_map(fn (string $path): ?string => str_contains($path, '/') ? $path : null, array_keys($files));
|
||||
|
||||
$page->script('(() => { const wire = Livewire.find(document.querySelector("[data-test=drop-zone]").closest("[wire\\\\:id]").getAttribute("wire:id")); wire.$set("relativePaths", '.json_encode($relativePaths).', false); wire._finishUpload("files", '.json_encode($signed).', true) })()');
|
||||
|
||||
$page->assertSee('Selected Files ('.count($files).')');
|
||||
}
|
||||
|
||||
$files = ['Q3 Report.pdf' => 2400, 'Contract 2026.pdf' => 380, 'Product photos/hero-shot.jpg' => 4800, 'Product photos/detail.jpg' => 3900];
|
||||
|
||||
test('desktop', function (string $theme) use ($files) {
|
||||
$upload = visit('/upload');
|
||||
$page = shotPage($upload, 'desktop', $theme);
|
||||
selectFiles($page, $files);
|
||||
$page->click('label:has-text("Password protect")')
|
||||
->type('input[wire\:model="password"]', DemoData::PROTECTED_PASSWORD)
|
||||
->type('input[wire\:model="maxDownloads"]', '5');
|
||||
// The drop zone, the files and the options fill the window; typing left the page wherever it scrolled.
|
||||
$page->script("document.activeElement?.blur(); window.scrollTo(0, document.querySelector('[data-test=drop-zone]').getBoundingClientRect().top + window.scrollY - 24)");
|
||||
shoot($page, 'desktop', $theme, '01-upload');
|
||||
|
||||
$created = visit(route('share.created', DemoData::DELIVERY_TOKEN, false));
|
||||
$page = shotPage($created, 'desktop', $theme);
|
||||
shoot($page, 'desktop', $theme, '02-share-created');
|
||||
|
||||
$page->click('[data-test="show-qr-code"]')
|
||||
->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open");
|
||||
shoot($page, 'desktop', $theme, '03-qr-code');
|
||||
|
||||
$download = visit(route('share.download', DemoData::DELIVERY_TOKEN, false));
|
||||
shoot(shotPage($download, 'desktop', $theme), 'desktop', $theme, '04-download');
|
||||
|
||||
$this->actingAs(DemoData::admin());
|
||||
|
||||
$dashboard = visit('/admin/dashboard');
|
||||
shoot(shotPage($dashboard, 'desktop', $theme), 'desktop', $theme, '05-admin-dashboard');
|
||||
|
||||
// Branding typed in but not saved, so the other shots keep SealShare's own.
|
||||
$settings = visit('/admin/settings');
|
||||
$page = shotPage($settings, 'desktop', $theme)
|
||||
->type('input[wire\\:model="siteTitle"]', 'Acme Files')
|
||||
->type('textarea[wire\\:model="siteDescription"]', 'Send and receive files securely with Acme Engineering.');
|
||||
$page->script('document.activeElement?.blur(); window.scrollTo(0, 0)');
|
||||
shoot($page, 'desktop', $theme, '06-admin-settings');
|
||||
})->with(['light', 'dark']);
|
||||
|
||||
test('phone', function (string $theme) use ($files) {
|
||||
$upload = visit('/upload');
|
||||
$page = shotPage($upload, 'phone', $theme);
|
||||
selectFiles($page, $files);
|
||||
shoot($page, 'phone', $theme, '01-upload');
|
||||
|
||||
$password = visit(route('share.download', DemoData::PROTECTED_TOKEN, false));
|
||||
shoot(shotPage($password, 'phone', $theme), 'phone', $theme, '02-password');
|
||||
|
||||
$download = visit(route('share.download', DemoData::DELIVERY_TOKEN, false));
|
||||
shoot(shotPage($download, 'phone', $theme), 'phone', $theme, '03-download');
|
||||
|
||||
$created = visit(route('share.created', DemoData::DELIVERY_TOKEN, false));
|
||||
$page = shotPage($created, 'phone', $theme);
|
||||
$page->click('[data-test="show-qr-code"]')
|
||||
->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open");
|
||||
shoot($page, 'phone', $theme, '04-qr-code');
|
||||
})->with(['light', 'dark']);
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Tests\Screenshots\Publisher;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->root = sys_get_temp_dir().'/sealshare-publisher-'.uniqid();
|
||||
mkdir($this->root.'/captures', 0755, true);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
exec('rm -rf '.escapeshellarg($this->root));
|
||||
});
|
||||
|
||||
test('a capture is published as WebP at each width, keeping its aspect ratio', function () {
|
||||
$capture = imagecreatetruecolor(3024, 1964);
|
||||
imagepng($capture, $this->root.'/captures/desktop-light-01-upload.png');
|
||||
|
||||
(new Publisher($this->root.'/captures', $this->root.'/site'))
|
||||
->publish('desktop-light-01-upload', 'desktop', 'light', '01-upload', [1600, 800]);
|
||||
|
||||
foreach ([1600 => 1039, 800 => 520] as $width => $height) {
|
||||
$path = $this->root."/site/desktop/light/01-upload-{$width}.webp";
|
||||
|
||||
expect($path)->toBeFile()
|
||||
->and(getimagesize($path))->toMatchArray([0 => $width, 1 => $height, 'mime' => 'image/webp']);
|
||||
}
|
||||
});
|
||||
|
||||
test('a capture that was not written fails the run', function () {
|
||||
(new Publisher($this->root.'/captures', $this->root.'/site'))
|
||||
->publish('phone-dark-02-password', 'phone', 'dark', '02-password', [1080]);
|
||||
})->throws(RuntimeException::class, 'The capture [phone-dark-02-password] was not written');
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Device frames drawn around SealShare's screenshots: a laptop for the desktop captures
|
||||
* (1512 × 982, a MacBook 14 viewport) and a phone for the phone captures (393 × 852, an iPhone 15
|
||||
* Pro viewport). The screen keeps the capture's aspect ratio, so the frame holds its shape while an
|
||||
* image loads. The screenshots come from `composer screenshots` (tests/Screenshots/).
|
||||
*
|
||||
* <div class="device device--laptop">
|
||||
* <div class="device__screen"><picture>…<img …></picture></div>
|
||||
* </div>
|
||||
*/
|
||||
|
||||
.device {
|
||||
--bezel: #1b1530;
|
||||
--bezel-edge: #3a3355;
|
||||
--deck: #c9c2dd;
|
||||
--deck-shadow: #8f88a8;
|
||||
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.device {
|
||||
--bezel: #05020f;
|
||||
--bezel-edge: #2a2244;
|
||||
--deck: #4b426a;
|
||||
--deck-shadow: #231649;
|
||||
}
|
||||
}
|
||||
|
||||
.device__screen {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--surface-container-high, #e8deff);
|
||||
}
|
||||
|
||||
.device__screen img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: top center;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- laptop */
|
||||
|
||||
.device--laptop {
|
||||
padding-bottom: 3.2%;
|
||||
margin-inline: 4%;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.device--laptop .device__lid {
|
||||
position: relative;
|
||||
padding: 2.4% 2.4% 3%;
|
||||
border-radius: 3.2% 3.2% 0 0 / 4.8% 4.8% 0 0;
|
||||
background: var(--bezel);
|
||||
box-shadow: inset 0 0 0 1px var(--bezel-edge), 0 24px 48px -24px rgb(16 6 45 / 0.45);
|
||||
}
|
||||
|
||||
.device--laptop .device__screen {
|
||||
aspect-ratio: 1512 / 982;
|
||||
border-radius: 0.6%;
|
||||
}
|
||||
|
||||
/* The camera notch at the top of the lid. */
|
||||
.device--laptop .device__lid::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0.9%;
|
||||
left: 50%;
|
||||
width: 1%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background: var(--bezel-edge);
|
||||
translate: -50% 0;
|
||||
}
|
||||
|
||||
/* The deck: wider than the lid, with a thumb recess in the middle. */
|
||||
.device--laptop::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -4%;
|
||||
right: -4%;
|
||||
bottom: 0;
|
||||
height: 3.2%;
|
||||
border-radius: 0 0 40% 40% / 0 0 100% 100%;
|
||||
background: linear-gradient(var(--deck), var(--deck-shadow));
|
||||
}
|
||||
|
||||
.device--laptop::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 1.8%;
|
||||
z-index: 1;
|
||||
width: 14%;
|
||||
height: 1.4%;
|
||||
border-radius: 0 0 12px 12px;
|
||||
background: var(--deck-shadow);
|
||||
translate: -50% 0;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- phone */
|
||||
|
||||
.device--phone {
|
||||
padding: 3.4%;
|
||||
border-radius: 15% / 7%;
|
||||
background: var(--bezel);
|
||||
box-shadow: inset 0 0 0 2px var(--bezel-edge), 0 24px 48px -20px rgb(16 6 45 / 0.5);
|
||||
}
|
||||
|
||||
.device--phone .device__screen {
|
||||
aspect-ratio: 393 / 852;
|
||||
border-radius: 12.5% / 5.8%;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,7 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 535 B |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" width="96" height="96">
|
||||
<!-- The SealShare mark: a document with a folded corner and an upload arrow (resources/views/components/app-logo-icon.blade.php). -->
|
||||
<path d="M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Z" stroke="#4a3fe2" stroke-width="1.5" />
|
||||
<path d="M14 2v5a1 1 0 0 0 1 1h5" stroke="#4a3fe2" stroke-width="1.5" />
|
||||
<path d="M12 17v-6m0 0-2.5 2.5M12 11l2.5 2.5" stroke="#4a3fe2" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 569 B |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,717 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SealShare — your own secure upload platform</title>
|
||||
<meta name="description" content="SealShare is self-hosted file sharing: your company's own upload platform to exchange files with customers, encrypted at rest, on your own server and domain. Open source, one Docker image.">
|
||||
<meta name="theme-color" content="#faf4ff" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#10062d" media="(prefers-color-scheme: dark)">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://sealshare.nonameweb.ch/">
|
||||
<meta property="og:title" content="SealShare — your own secure upload platform">
|
||||
<meta property="og:description" content="SealShare is self-hosted file sharing: your company's own upload platform to exchange files with customers, encrypted at rest, on your own server and domain. Open source, one Docker image.">
|
||||
<meta property="og:image" content="https://sealshare.nonameweb.ch/img/screenshots/desktop/light/01-upload-1600.webp">
|
||||
<!-- Privacy-friendly analytics by Plausible -->
|
||||
<script async src="https://plausible.io/js/pa-zWB4R2-rrBeLfwms3uIBT.js"></script>
|
||||
<script>
|
||||
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
|
||||
plausible.init()
|
||||
</script>
|
||||
<link rel="icon" href="img/favicon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="img/icon.png">
|
||||
<link rel="preload" href="fonts/GoogleSansFlex-Latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="stylesheet" href="css/theme.css">
|
||||
<link rel="stylesheet" href="css/device-frame.css">
|
||||
</head>
|
||||
<body id="top">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="sprite" aria-hidden="true">
|
||||
<symbol id="i-cloud-upload" viewBox="0 -960 960 960"><path d="m440-446-36 35q-11 11-27.5 11T348-412q-11-11-11-28t11-28l104-104q12-12 28-12t28 12l104 104q11 11 11.5 27.5T612-412q-11 11-27.5 11.5T556-411l-36-35v206h220q42 0 71-29t29-71q0-42-29-71t-71-29h-60v-80q0-83-58.5-141.5T480-720q-83 0-141.5 58.5T280-520h-20q-58 0-99 41t-41 99q0 58 41 99t99 41h60q17 0 28.5 11.5T360-200q0 17-11.5 28.5T320-160h-60q-91 0-155.5-63T40-377q0-78 47-139t123-78q25-92 100-149t170-57q117 0 198.5 81.5T760-520q69 8 114.5 59.5T920-340q0 75-52.5 127.5T740-160H520q-33 0-56.5-23.5T440-240v-206Zm40 6Z"/></symbol>
|
||||
<symbol id="i-link" viewBox="0 -960 960 960"><path d="M280-280q-83 0-141.5-58.5T80-480q0-83 58.5-141.5T280-680h120q17 0 28.5 11.5T440-640q0 17-11.5 28.5T400-600H280q-50 0-85 35t-35 85q0 50 35 85t85 35h120q17 0 28.5 11.5T440-320q0 17-11.5 28.5T400-280H280Zm80-160q-17 0-28.5-11.5T320-480q0-17 11.5-28.5T360-520h240q17 0 28.5 11.5T640-480q0 17-11.5 28.5T600-440H360Zm200 160q-17 0-28.5-11.5T520-320q0-17 11.5-28.5T560-360h120q50 0 85-35t35-85q0-50-35-85t-85-35H560q-17 0-28.5-11.5T520-640q0-17 11.5-28.5T560-680h120q83 0 141.5 58.5T880-480q0 83-58.5 141.5T680-280H560Z"/></symbol>
|
||||
<symbol id="i-qr-code-2" viewBox="0 -960 960 960"><path d="M520-120v-80h80v80h-80Zm-80-80v-200h80v200h-80Zm320-120v-160h80v160h-80Zm-80-160v-80h80v80h-80Zm-480 80v-80h80v80h-80Zm-80-80v-80h80v80h-80Zm360-280v-80h80v80h-80ZM180-660h120v-120H180v120Zm-60 20v-160q0-17 11.5-28.5T160-840h160q17 0 28.5 11.5T360-800v160q0 17-11.5 28.5T320-600H160q-17 0-28.5-11.5T120-640Zm60 460h120v-120H180v120Zm-60 20v-160q0-17 11.5-28.5T160-360h160q17 0 28.5 11.5T360-320v160q0 17-11.5 28.5T320-120H160q-17 0-28.5-11.5T120-160Zm540-500h120v-120H660v120Zm-60 20v-160q0-17 11.5-28.5T640-840h160q17 0 28.5 11.5T840-800v160q0 17-11.5 28.5T800-600H640q-17 0-28.5-11.5T600-640Zm80 520v-120h-80v-80h160v120h80v80H680ZM520-400v-80h160v80H520Zm-160 0v-80h-80v-80h240v80h-80v80h-80Zm40-200v-160h80v80h80v80H400Zm-190-90v-60h60v60h-60Zm0 480v-60h60v60h-60Zm480-480v-60h60v60h-60Z"/></symbol>
|
||||
<symbol id="i-lock" viewBox="0 -960 960 960"><path d="M240-80q-33 0-56.5-23.5T160-160v-400q0-33 23.5-56.5T240-640h40v-80q0-83 58.5-141.5T480-920q83 0 141.5 58.5T680-720v80h40q33 0 56.5 23.5T800-560v400q0 33-23.5 56.5T720-80H240Zm0-80h480v-400H240v400Zm240-120q33 0 56.5-23.5T560-360q0-33-23.5-56.5T480-440q-33 0-56.5 23.5T400-360q0 33 23.5 56.5T480-280ZM360-640h240v-80q0-50-35-85t-85-35q-50 0-85 35t-35 85v80ZM240-160v-400 400Z"/></symbol>
|
||||
<symbol id="i-schedule" viewBox="0 -960 960 960"><path d="M520-496v-144q0-17-11.5-28.5T480-680q-17 0-28.5 11.5T440-640v159q0 8 3 15.5t9 13.5l132 132q11 11 28 11t28-11q11-11 11-28t-11-28L520-496ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-400Zm0 320q133 0 226.5-93.5T800-480q0-133-93.5-226.5T480-800q-133 0-226.5 93.5T160-480q0 133 93.5 226.5T480-160Z"/></symbol>
|
||||
<symbol id="i-download" viewBox="0 -960 960 960"><path d="M480-337q-8 0-15-2.5t-13-8.5L308-492q-12-12-11.5-28t11.5-28q12-12 28.5-12.5T365-549l75 75v-286q0-17 11.5-28.5T480-800q17 0 28.5 11.5T520-760v286l75-75q12-12 28.5-11.5T652-548q11 12 11.5 28T652-492L508-348q-6 6-13 8.5t-15 2.5ZM240-160q-33 0-56.5-23.5T160-240v-80q0-17 11.5-28.5T200-360q17 0 28.5 11.5T240-320v80h480v-80q0-17 11.5-28.5T760-360q17 0 28.5 11.5T800-320v80q0 33-23.5 56.5T720-160H240Z"/></symbol>
|
||||
<symbol id="i-folder-zip" viewBox="0 -960 960 960"><path d="M640-480v-80h80v80h-80Zm0 80h-80v-80h80v80Zm0 80v-80h80v80h-80ZM447-640l-80-80H160v480h400v-80h80v80h160v-400H640v80h-80v-80H447ZM160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h207q16 0 30.5 6t25.5 17l57 57h320q33 0 56.5 23.5T880-640v400q0 33-23.5 56.5T800-160H160Zm0-80v-480 480Z"/></symbol>
|
||||
<symbol id="i-delete-sweep" viewBox="0 -960 960 960"><path d="M200-200q-33 0-56.5-23.5T120-280v-360q-17 0-28.5-11.5T80-680q0-17 11.5-28.5T120-720h120v-20q0-17 11.5-28.5T280-780h80q17 0 28.5 11.5T400-740v20h120q17 0 28.5 11.5T560-680q0 17-11.5 28.5T520-640v360q0 33-23.5 56.5T440-200H200Zm440-40q-17 0-28.5-11.5T600-280q0-17 11.5-28.5T640-320h80q17 0 28.5 11.5T760-280q0 17-11.5 28.5T720-240h-80Zm0-160q-17 0-28.5-11.5T600-440q0-17 11.5-28.5T640-480h160q17 0 28.5 11.5T840-440q0 17-11.5 28.5T800-400H640Zm0-160q-17 0-28.5-11.5T600-600q0-17 11.5-28.5T640-640h200q17 0 28.5 11.5T880-600q0 17-11.5 28.5T840-560H640Zm-440-80v360h240v-360H200Z"/></symbol>
|
||||
<symbol id="i-admin-panel-settings" viewBox="0 -960 960 960"><path d="M680-280q25 0 42.5-17.5T740-340q0-25-17.5-42.5T680-400q-25 0-42.5 17.5T620-340q0 25 17.5 42.5T680-280Zm0 120q31 0 57-14.5t42-38.5q-22-13-47-20t-52-7q-27 0-52 7t-47 20q16 24 42 38.5t57 14.5ZM480-874q8 0 14.5 1t13.5 4l240 90q23 9 37.5 29t14.5 45v125q0 17-11.5 28.5T760-540q-17 0-28.5-11.5T720-580v-124l-240-90-240 90v188q0 50 14.5 100t40 95q25.5 45 62 81t79.5 59q15 8 21.5 23t.5 30q-7 16-22.5 22t-30.5-2q-113-56-179-169t-66-239v-189q0-25 14.5-45t37.5-29l240-90q7-3 14-4t14-1ZM680-80q-83 0-141.5-58.5T480-280q0-83 58.5-141.5T680-480q83 0 141.5 58.5T880-280q0 83-58.5 141.5T680-80ZM480-494Z"/></symbol>
|
||||
<symbol id="i-palette" viewBox="0 -960 960 960"><path d="M480-80q-82 0-155-31.5t-127.5-86Q143-252 111.5-325T80-480q0-83 32.5-156t88-127Q256-817 330-848.5T488-880q80 0 151 27.5t124.5 76q53.5 48.5 85 115T880-518q0 115-70 176.5T640-280h-74q-9 0-12.5 5t-3.5 11q0 12 15 34.5t15 51.5q0 50-27.5 74T480-80Zm0-400Zm-220 40q26 0 43-17t17-43q0-26-17-43t-43-17q-26 0-43 17t-17 43q0 26 17 43t43 17Zm120-160q26 0 43-17t17-43q0-26-17-43t-43-17q-26 0-43 17t-17 43q0 26 17 43t43 17Zm200 0q26 0 43-17t17-43q0-26-17-43t-43-17q-26 0-43 17t-17 43q0 26 17 43t43 17Zm120 160q26 0 43-17t17-43q0-26-17-43t-43-17q-26 0-43 17t-17 43q0 26 17 43t43 17ZM480-160q9 0 14.5-5t5.5-13q0-14-15-33t-15-57q0-42 29-67t71-25h70q66 0 113-38.5T800-518q0-121-92.5-201.5T488-800q-136 0-232 93t-96 227q0 133 93.5 226.5T480-160Z"/></symbol>
|
||||
<symbol id="i-shield-lock" viewBox="0 -960 960 960"><path d="M400-320h160q17 0 28.5-11.5T600-360v-120q0-17-11.5-28.5T560-520v-40q0-33-23.5-56.5T480-640q-33 0-56.5 23.5T400-560v40q-17 0-28.5 11.5T360-480v120q0 17 11.5 28.5T400-320Zm40-200v-40q0-17 11.5-28.5T480-600q17 0 28.5 11.5T520-560v40h-80Zm40 436q-7 0-13-1t-12-3q-135-45-215-166.5T160-516v-189q0-25 14.5-45t37.5-29l240-90q14-5 28-5t28 5l240 90q23 9 37.5 29t14.5 45v189q0 140-80 261.5T505-88q-6 2-12 3t-13 1Zm0-80q104-33 172-132t68-220v-189l-240-90-240 90v189q0 121 68 220t172 132Zm0-316Z"/></symbol>
|
||||
<symbol id="i-phonelink-lock" viewBox="0 -960 960 960"><path d="M634-320q-14 0-24-10t-10-24v-132q0-14 10-24t24-10h6v-40q0-33 23.5-56.5T720-640q33 0 56.5 23.5T800-560v40h6q14 0 24 10t10 24v132q0 14-10 24t-24 10H634Zm46-200h80v-40q0-17-11.5-28.5T720-600q-17 0-28.5 11.5T680-560v40ZM280-40q-33 0-56.5-23.5T200-120v-720q0-33 23.5-56.5T280-920h400q33 0 56.5 23.5T760-840v120q0 17-11.5 28.5T720-680q-17 0-28.5-11.5T680-720H280v480h400q0-17 11.5-28.5T720-280q17 0 28.5 11.5T760-240v120q0 33-23.5 56.5T680-40H280Zm0-120v40h400v-40H280Zm0-640h400v-40H280v40Zm0 0v-40 40Zm0 640v40-40Z"/></symbol>
|
||||
<symbol id="i-deployed-code" viewBox="0 -960 960 960"><path d="M440-183v-274L200-596v274l240 139Zm80 0 240-139v-274L520-457v274Zm-40-343 237-137-237-137-237 137 237 137ZM160-252q-19-11-29.5-29T120-321v-318q0-22 10.5-40t29.5-29l280-161q19-11 40-11t40 11l280 161q19 11 29.5 29t10.5 40v318q0 22-10.5 40T800-252L520-91q-19 11-40 11t-40-11L160-252Zm320-228Z"/></symbol>
|
||||
<symbol id="i-dns" viewBox="0 -960 960 960"><path d="M300-720q-25 0-42.5 17.5T240-660q0 25 17.5 42.5T300-600q25 0 42.5-17.5T360-660q0-25-17.5-42.5T300-720Zm0 400q-25 0-42.5 17.5T240-260q0 25 17.5 42.5T300-200q25 0 42.5-17.5T360-260q0-25-17.5-42.5T300-320ZM160-840h640q17 0 28.5 11.5T840-800v280q0 17-11.5 28.5T800-480H160q-17 0-28.5-11.5T120-520v-280q0-17 11.5-28.5T160-840Zm40 80v200h560v-200H200Zm-40 320h640q17 0 28.5 11.5T840-400v280q0 17-11.5 28.5T800-80H160q-17 0-28.5-11.5T120-120v-280q0-17 11.5-28.5T160-440Zm40 80v200h560v-200H200Zm0-400v200-200Zm0 400v200-200Z"/></symbol>
|
||||
<symbol id="i-no-accounts" viewBox="0 -960 960 960"><path d="M608-522 422-708q14-6 28.5-9t29.5-3q59 0 99.5 40.5T620-580q0 15-3 29.5t-9 28.5ZM234-276q51-39 114-61.5T480-360q18 0 34.5 1.5T549-354l-88-88q-47-6-80.5-39.5T341-562L227-676q-32 41-49.5 90.5T160-480q0 59 19.5 111t54.5 93Zm498-8q32-41 50-90.5T800-480q0-133-93.5-226.5T480-800q-56 0-105.5 18T284-732l448 448ZM480-80q-82 0-155-31.5t-127.5-86Q143-252 111.5-325T80-480q0-83 31.5-155.5t86-127Q252-817 325-848.5T480-880q83 0 155.5 31.5t127 86q54.5 54.5 86 127T880-480q0 82-31.5 155t-86 127.5q-54.5 54.5-127 86T480-80Zm0-80q53 0 100-15.5t86-44.5q-39-29-86-44.5T480-280q-53 0-100 15.5T294-220q39 29 86 44.5T480-160Zm0-60Z"/></symbol>
|
||||
<symbol id="i-money-off" viewBox="0 -960 960 960"><path d="M440-756v-44q0-17 11.5-28.5T480-840q17 0 28.5 11.5T520-800v42q30 6 56.5 21.5T623-698q11 14 9.5 30.5T617-640q-14 11-30.5 9.5T559-646q-14-17-34-26.5t-43-9.5q-11 0-21.5 3t-20.5 8q-16 7-31.5 1T386-692q-7-16-1-31.5t22-22.5q8-4 16.5-6t16.5-4ZM764-84 608-240q-15 15-41 24.5T520-204v44q0 17-11.5 28.5T480-120q-17 0-28.5-11.5T440-160v-46q-44-11-77-36.5T310-306q-8-16-3-33t21-25q17-8 33.5-2t25.5 22q15 28 40.5 46t60.5 18q18 0 33-4.5t29-13.5L84-764q-11-11-11-28t11-28q11-11 28-11t28 11l680 680q11 11 11 28t-11 28q-11 11-28 11t-28-11Z"/></symbol>
|
||||
<symbol id="i-drive-folder-upload" viewBox="0 -960 960 960"><path d="M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h207q16 0 30.5 6t25.5 17l57 57h320q33 0 56.5 23.5T880-640v400q0 33-23.5 56.5T800-160H160Zm0-80h640v-400H447l-80-80H160v480Zm0 0v-480 480Zm320-40q17 0 28.5-11.5T520-320v-128l36 36q11 11 28 11t28-11q11-11 11-28t-11-28L508-572q-12-12-28-12t-28 12L348-468q-11 11-11 28t11 28q11 11 28 11t28-11l36-36v128q0 17 11.5 28.5T480-280Z"/></symbol>
|
||||
<symbol id="i-counter-5" viewBox="0 -960 960 960"><path d="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Zm-80 200h120q33 0 56.5-23.5T600-360v-80q0-33-23.5-56.5T520-520h-80v-80h120q17 0 28.5-11.5T600-640q0-17-11.5-28.5T560-680H400q-17 0-28.5 11.5T360-640v160q0 17 11.5 28.5T400-440h120v80H400q-17 0-28.5 11.5T360-320q0 17 11.5 28.5T400-280Z"/></symbol>
|
||||
<symbol id="i-check" viewBox="0 -960 960 960"><path d="m382-354 339-339q12-12 28-12t28 12q12 12 12 28.5T777-636L410-268q-12 12-28 12t-28-12L182-440q-12-12-11.5-28.5T183-497q12-12 28.5-12t28.5 12l142 143Z"/></symbol>
|
||||
<symbol id="i-close" viewBox="0 -960 960 960"><path d="M480-424 284-228q-11 11-28 11t-28-11q-11-11-11-28t11-28l196-196-196-196q-11-11-11-28t11-28q11-11 28-11t28 11l196 196 196-196q11-11 28-11t28 11q11 11 11 28t-11 28L536-480l196 196q11 11 11 28t-11 28q-11 11-28 11t-28-11L480-424Z"/></symbol>
|
||||
<symbol id="i-menu" viewBox="0 -960 960 960"><path d="M160-240q-17 0-28.5-11.5T120-280q0-17 11.5-28.5T160-320h640q17 0 28.5 11.5T840-280q0 17-11.5 28.5T800-240H160Zm0-200q-17 0-28.5-11.5T120-480q0-17 11.5-28.5T160-520h640q17 0 28.5 11.5T840-480q0 17-11.5 28.5T800-440H160Zm0-200q-17 0-28.5-11.5T120-680q0-17 11.5-28.5T160-720h640q17 0 28.5 11.5T840-680q0 17-11.5 28.5T800-640H160Z"/></symbol>
|
||||
<symbol id="i-mail" viewBox="0 -960 960 960"><path d="M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H160Zm640-480L501-453q-5 3-10.5 4.5T480-447q-5 0-10.5-1.5T459-453L160-640v400h640v-400ZM480-520l320-200H160l320 200ZM160-640v10-59 1-32 32-.5 58.5-10 400-400Z"/></symbol>
|
||||
<symbol id="i-arrow-forward" viewBox="0 -960 960 960"><path d="M647-440H200q-17 0-28.5-11.5T160-480q0-17 11.5-28.5T200-520h447L451-716q-12-12-11.5-28t12.5-28q12-11 28-11.5t28 11.5l264 264q6 6 8.5 13t2.5 15q0 8-2.5 15t-8.5 13L508-188q-11 11-27.5 11T452-188q-12-12-12-28.5t12-28.5l195-195Z"/></symbol>
|
||||
<symbol id="i-open-in-new" viewBox="0 -960 960 960"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h240q17 0 28.5 11.5T480-800q0 17-11.5 28.5T440-760H200v560h560v-240q0-17 11.5-28.5T800-480q17 0 28.5 11.5T840-440v240q0 33-23.5 56.5T760-120H200Zm560-584L416-360q-11 11-28 11t-28-11q-11-11-11-28t11-28l344-344H600q-17 0-28.5-11.5T560-800q0-17 11.5-28.5T600-840h200q17 0 28.5 11.5T840-800v200q0 17-11.5 28.5T800-560q-17 0-28.5-11.5T760-600v-104Z"/></symbol>
|
||||
<symbol id="i-terminal" viewBox="0 -960 960 960"><path d="M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H160Zm0-80h640v-400H160v400Zm187-200-76-76q-12-12-11.5-28t12.5-28q12-11 28-11.5t28 11.5l104 104q12 12 12 28t-12 28L328-308q-11 11-27.5 11.5T272-308q-11-11-11-28t11-28l75-76Zm173 160q-17 0-28.5-11.5T480-320q0-17 11.5-28.5T520-360h160q17 0 28.5 11.5T720-320q0 17-11.5 28.5T680-280H520Z"/></symbol>
|
||||
<symbol id="i-expand-more" viewBox="0 -960 960 960"><path d="M480-362q-8 0-15-2.5t-13-8.5L268-557q-11-11-11-28t11-28q11-11 28-11t28 11l156 156 156-156q11-11 28-11t28 11q11 11 11 28t-11 28L508-373q-6 6-13 8.5t-15 2.5Z"/></symbol>
|
||||
<symbol id="i-encrypted" viewBox="0 -960 960 960"><path d="M444-360h72q9 0 15.5-7.5T536-384l-19-105q20-10 31.5-29t11.5-42q0-33-23.5-56.5T480-640q-33 0-56.5 23.5T400-560q0 23 11.5 42t31.5 29l-19 105q-2 9 4.5 16.5T444-360Zm36 276q-7 0-13-1t-12-3q-135-45-215-166.5T160-516v-189q0-25 14.5-45t37.5-29l240-90q14-5 28-5t28 5l240 90q23 9 37.5 29t14.5 45v189q0 140-80 261.5T505-88q-6 2-12 3t-13 1Zm0-80q104-33 172-132t68-220v-189l-240-90-240 90v189q0 121 68 220t172 132Zm0-316Z"/></symbol>
|
||||
<symbol id="i-dashboard" viewBox="0 -960 960 960"><path d="M520-640v-160q0-17 11.5-28.5T560-840h240q17 0 28.5 11.5T840-800v160q0 17-11.5 28.5T800-600H560q-17 0-28.5-11.5T520-640ZM120-480v-320q0-17 11.5-28.5T160-840h240q17 0 28.5 11.5T440-800v320q0 17-11.5 28.5T400-440H160q-17 0-28.5-11.5T120-480Zm400 320v-320q0-17 11.5-28.5T560-520h240q17 0 28.5 11.5T840-480v320q0 17-11.5 28.5T800-120H560q-17 0-28.5-11.5T520-160Zm-400 0v-160q0-17 11.5-28.5T160-360h240q17 0 28.5 11.5T440-320v160q0 17-11.5 28.5T400-120H160q-17 0-28.5-11.5T120-160Zm80-360h160v-240H200v240Zm400 320h160v-240H600v240Zm0-480h160v-80H600v80ZM200-200h160v-80H200v80Zm160-320Zm240-160Zm0 240ZM360-280Z"/></symbol>
|
||||
<symbol id="i-bug-report" viewBox="0 -960 960 960"><path d="M480-120q-65 0-120.5-32T272-240h-72q-17 0-28.5-11.5T160-280q0-17 11.5-28.5T200-320h44q-3-20-3.5-40t-.5-40h-40q-17 0-28.5-11.5T160-440q0-17 11.5-28.5T200-480h40q0-20 .5-40t3.5-40h-44q-17 0-28.5-11.5T160-600q0-17 11.5-28.5T200-640h72q14-23 31.5-43t40.5-35l-37-38q-11-11-11-27.5t12-28.5q11-11 28-11t28 11l58 58q28-9 57-9t57 9l60-59q11-11 27.5-11t28.5 12q11 11 11 28t-11 28l-38 38q23 15 41.5 34.5T688-640h72q17 0 28.5 11.5T800-600q0 17-11.5 28.5T760-560h-44q3 20 3.5 40t.5 40h40q17 0 28.5 11.5T800-440q0 17-11.5 28.5T760-400h-40q0 20-.5 40t-3.5 40h44q17 0 28.5 11.5T800-280q0 17-11.5 28.5T760-240h-72q-32 56-87.5 88T480-120Zm0-80q66 0 113-47t47-113v-160q0-66-47-113t-113-47q-66 0-113 47t-47 113v160q0 66 47 113t113 47Zm-40-120h80q17 0 28.5-11.5T560-360q0-17-11.5-28.5T520-400h-80q-17 0-28.5 11.5T400-360q0 17 11.5 28.5T440-320Zm0-160h80q17 0 28.5-11.5T560-520q0-17-11.5-28.5T520-560h-80q-17 0-28.5 11.5T400-520q0 17 11.5 28.5T440-480Zm40 40Z"/></symbol>
|
||||
<symbol id="s-cookie-9" viewBox="0 0 100 100"><path d="M50 2.5C53.6 2.5 57.3 3.8 60.2 6.4C63.1 9.1 66.9 10.5 70.9 10.3C78.7 10 85.4 15.7 86.4 23.4C87 27.3 89 30.8 92.1 33.3C98.3 38 99.8 46.7 95.7 53.3C93.6 56.6 92.9 60.6 93.7 64.5C95.3 72.1 90.9 79.7 83.5 82.1C79.8 83.3 76.6 85.9 74.8 89.4C71.2 96.3 62.9 99.3 55.7 96.4C52.1 94.9 47.9 94.9 44.3 96.4C37.1 99.3 28.8 96.3 25.2 89.4C23.4 85.9 20.2 83.3 16.5 82.1C9.1 79.7 4.7 72.1 6.3 64.5C7.1 60.6 6.4 56.6 4.3 53.3C0.2 46.7 1.7 38 7.9 33.3C11 30.8 13 27.3 13.6 23.4C14.6 15.7 21.3 10 29.1 10.3C33.1 10.5 36.9 9.1 39.8 6.4C42.7 3.8 46.4 2.5 50 2.5Z"/></symbol>
|
||||
<symbol id="s-soft-burst" viewBox="0 0 100 100"><path d="M19.4 27.8C20.1 26.8 20.5 25.7 20.4 24.4C20.1 21.5 19.9 18.6 19.7 15.7C19.4 11.9 23.2 9.2 26.6 10.6C29.4 11.7 32.1 12.8 34.8 13.9C37.1 14.9 39.8 14 41 11.9C42.6 9.4 44.1 6.9 45.7 4.4C47.6 1.2 52.3 1.2 54.3 4.4C55.8 6.9 57.4 9.4 58.9 11.9C60.2 14 62.9 14.9 65.2 13.9C67.9 12.8 70.6 11.7 73.3 10.6C76.8 9.1 80.5 11.9 80.3 15.6C80 18.5 79.8 21.5 79.6 24.4C79.4 26.9 81.1 29.1 83.5 29.7C86.3 30.4 89.2 31.1 92 31.8C95.7 32.7 97.1 37.1 94.7 40C92.8 42.2 90.9 44.5 89 46.7C87.4 48.6 87.4 51.4 89 53.3C90.9 55.5 92.8 57.7 94.7 60C97.1 62.8 95.7 67.3 92.1 68.2C89.2 68.9 86.3 69.6 83.5 70.3C81.1 70.9 79.5 73.1 79.6 75.6C79.9 78.5 80.1 81.4 80.3 84.3C80.6 88.1 76.8 90.8 73.4 89.4C70.6 88.3 67.9 87.2 65.2 86.1C62.9 85.1 60.2 86 59 88.1C57.4 90.6 55.9 93.1 54.3 95.6C52.4 98.8 47.7 98.8 45.7 95.6C44.2 93.1 42.6 90.6 41.1 88.1C39.8 86 37.1 85.1 34.8 86.1C32.1 87.2 29.4 88.3 26.7 89.4C23.2 90.9 19.5 88.1 19.7 84.4C20 81.5 20.2 78.5 20.4 75.6C20.6 73.1 18.9 70.9 16.5 70.3C13.7 69.6 10.8 68.9 8 68.2C4.3 67.3 2.9 62.9 5.3 60C7.2 57.8 9.1 55.5 11 53.3C12.6 51.4 12.6 48.6 11 46.7C9.1 44.5 7.2 42.3 5.3 40C2.9 37.2 4.3 32.7 7.9 31.8C10.8 31.1 13.7 30.4 16.5 29.7C17.7 29.4 18.7 28.7 19.4 27.8Z"/></symbol>
|
||||
<symbol id="s-clover-4" viewBox="0 0 100 100"><path d="M50 9.1C50.5 8.7 51 8.4 51.5 8C56.7 4 62.9 2 69.1 2C76.5 2 83.9 4.9 89.5 10.5C95.1 16.1 98 23.5 98 30.9C98 37.1 96 43.3 92 48.5C91.6 49 91.3 49.5 90.9 50C91.3 50.5 91.6 51 92 51.5C96 56.7 98 62.9 98 69.1C98 76.5 95.1 83.9 89.5 89.5C83.9 95.1 76.5 98 69.1 98C62.9 98 56.7 96 51.5 92C51 91.6 50.5 91.3 50 90.9C49.5 91.3 49 91.6 48.5 92C43.3 96 37.1 98 30.9 98C23.5 98 16.1 95.1 10.5 89.5C4.9 83.9 2 76.5 2 69.1C2 62.9 4 56.7 8 51.5C8.4 51 8.7 50.5 9.1 50C8.7 49.5 8.4 49 8 48.5C4 43.3 2 37.1 2 30.9C2 23.5 4.9 16.1 10.5 10.5C16.1 4.9 23.5 2 30.9 2C37.1 2 43.3 4 48.5 8C49 8.4 49.5 8.7 50 9.1Z"/></symbol>
|
||||
<symbol id="s-puffy-diamond" viewBox="0 0 100 100"><path d="M76.8 23.2C80 26.4 81.7 31.2 80.5 36.3C81 36.2 81.5 36.1 82 36.1C90.5 34.9 98 41.5 98 50C98 58.5 90.5 65.1 82 63.9C81.5 63.9 81 63.8 80.5 63.7C82.8 73.8 73.8 82.8 63.7 80.5C63.8 81 63.9 81.5 63.9 82C65.1 90.5 58.5 98 50 98C41.5 98 34.9 90.5 36.1 82C36.1 81.5 36.2 81 36.3 80.5C26.2 82.8 17.2 73.8 19.5 63.7C19 63.8 18.5 63.9 18 63.9C9.5 65.1 2 58.5 2 50C2 41.5 9.5 34.9 18 36.1C18.5 36.1 19 36.2 19.5 36.3C17.2 26.2 26.2 17.2 36.3 19.5C36.2 19 36.1 18.5 36.1 18C34.9 9.5 41.5 2 50 2C58.5 2 65.1 9.5 63.9 18C63.9 18.5 63.8 19 63.7 19.5C68.8 18.3 73.6 20 76.8 23.2Z"/></symbol>
|
||||
<symbol id="s-sunny" viewBox="0 0 100 100"><path d="M98 50C98 51.7 97.4 53.5 96.2 54.9C93.7 57.8 91.3 60.7 88.8 63.6C87.8 64.8 87.2 66.3 87 67.9C86.7 71.6 86.4 75.4 86.1 79.2C85.8 82.9 82.9 85.8 79.2 86.1C75.4 86.4 71.6 86.7 67.9 87C66.3 87.2 64.8 87.8 63.6 88.8C60.7 91.3 57.8 93.7 54.9 96.2C52.1 98.6 47.9 98.6 45.1 96.2C42.2 93.7 39.3 91.3 36.4 88.8C35.2 87.8 33.7 87.2 32.1 87C28.4 86.7 24.6 86.4 20.8 86.1C17.1 85.8 14.2 82.9 13.9 79.2C13.6 75.4 13.3 71.6 13 67.9C12.8 66.3 12.2 64.8 11.2 63.6C8.7 60.7 6.3 57.8 3.8 54.9C1.4 52.1 1.4 47.9 3.8 45.1C6.3 42.2 8.7 39.3 11.2 36.4C12.2 35.2 12.8 33.7 13 32.1C13.3 28.4 13.6 24.6 13.9 20.8C14.2 17.1 17.1 14.2 20.8 13.9C24.6 13.6 28.4 13.3 32.1 13C33.7 12.8 35.2 12.2 36.4 11.2C39.3 8.7 42.2 6.3 45.1 3.8C47.9 1.4 52.1 1.4 54.9 3.8C57.8 6.3 60.7 8.7 63.6 11.2C64.8 12.2 66.3 12.8 67.9 13C71.6 13.3 75.4 13.6 79.2 13.9C82.9 14.2 85.8 17.1 86.1 20.8C86.4 24.6 86.7 28.4 87 32.1C87.2 33.7 87.8 35.2 88.8 36.4C91.3 39.3 93.7 42.2 96.2 45.1C97.4 46.5 98 48.3 98 50Z"/></symbol>
|
||||
<symbol id="s-cookie-6" viewBox="0 0 100 100"><path d="M71.8 87.5C70.1 88.5 68.5 89.7 67.1 91C67 91.1 67 91.1 66.9 91.2C62.2 95.7 56.1 98 50 98C43.9 98 37.8 95.7 33.1 91.2C30.3 88.4 26.8 86.4 23 85.3C22.9 85.3 22.9 85.3 22.8 85.3C10.2 81.6 2.7 68.7 5.9 55.9C6.9 52.1 6.9 48.1 5.9 44.3C5.9 44.2 5.9 44.1 5.9 44.1C2.7 31.3 10.2 18.4 22.8 14.8C26.6 13.7 30.1 11.7 32.9 9C33 8.9 33 8.9 33.1 8.8C37.8 4.3 43.9 2 50 2C56.1 2 62.2 4.3 66.9 8.8C69.7 11.6 73.2 13.6 77 14.7C77.1 14.7 77.1 14.7 77.2 14.7C89.8 18.4 97.3 31.3 94.1 44.1C93.1 47.9 93.1 51.9 94.1 55.7C94.1 55.8 94.1 55.9 94.1 55.9C97.3 68.7 89.8 81.6 77.2 85.2C75.3 85.8 73.5 86.5 71.8 87.5Z"/></symbol>
|
||||
<symbol id="s-gem" viewBox="0 0 100 100"><path d="M49.9 98C49.2 98 48.4 98 47.6 97.9C43.8 97.5 40.2 95.8 32.9 92.5C26.9 89.8 21 87 15.1 84.3C7 80.6 2.4 72.1 3.7 63.3C5 54.2 6.3 45.1 7.7 36C8.5 30.1 11.7 24.8 16.6 21.4C23.8 16.2 31 11 38.3 5.8C41.7 3.3 45.9 2 50.1 2C54.4 2 58.5 3.4 61.9 5.8C69.2 11.1 76.4 16.3 83.6 21.5C88.4 25 91.6 30.3 92.5 36.2C93.8 45.3 95.1 54.4 96.3 63.5C97.6 72.2 92.9 80.8 84.8 84.5C78.9 87.2 73 89.8 67 92.5C59.7 95.9 56.1 97.5 52.3 97.9C51.5 98 50.7 98 49.9 98Z"/></symbol>
|
||||
</svg>
|
||||
|
||||
<header class="nav">
|
||||
<div class="container nav__bar">
|
||||
<a class="brand" href="#top"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><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"/><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><span>SealShare</span></a>
|
||||
<button class="nav__toggle" id="navToggle" type="button" aria-expanded="false" aria-controls="navLinks" aria-label="Menu">
|
||||
<svg class="icon icon-menu" aria-hidden="true"><use href="#i-menu"/></svg>
|
||||
<svg class="icon icon-close" aria-hidden="true"><use href="#i-close"/></svg>
|
||||
</button>
|
||||
<nav aria-label="Main">
|
||||
<ul class="nav__links" id="navLinks">
|
||||
<li><a href="#why">Why</a></li>
|
||||
<li><a href="#features">Features</a></li>
|
||||
<li><a href="#screenshots">Screenshots</a></li>
|
||||
<li><a href="#compare">Compare</a></li>
|
||||
<li><a href="#install">Install</a></li>
|
||||
<li><a href="#faq">FAQ</a></li>
|
||||
<li><a class="btn btn--filled" href="https://gitea.nonameweb.ch/noNameWEB/SealShare"><svg class="icon" aria-hidden="true"><use href="#i-open-in-new"/></svg>Gitea</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
|
||||
<section class="hero" aria-labelledby="hero-title">
|
||||
<div class="container hero__grid">
|
||||
<div class="hero__copy">
|
||||
<span class="eyebrow"><svg class="icon" aria-hidden="true"><use href="#i-dns"/></svg>Self-hosted · Open source</span>
|
||||
<h1 class="display" id="hero-title">Your own secure upload platform</h1>
|
||||
<p class="lede">SealShare is file sharing software you install on your own server. Your company gets its own upload page to send files to customers and receive files from them — encrypted at rest, on your domain, without relying on an outside service.</p>
|
||||
<div class="hero__actions">
|
||||
<a class="btn btn--filled btn--lg" href="#install">Install SealShare<svg class="icon" aria-hidden="true"><use href="#i-arrow-forward"/></svg></a>
|
||||
<a class="btn btn--outlined btn--lg" href="https://gitea.nonameweb.ch/noNameWEB/SealShare"><svg class="icon" aria-hidden="true"><use href="#i-open-in-new"/></svg>Source on Gitea</a>
|
||||
</div>
|
||||
<ul class="hero__facts">
|
||||
<li><svg class="icon" aria-hidden="true"><use href="#i-check"/></svg>No accounts for customers</li>
|
||||
<li><svg class="icon" aria-hidden="true"><use href="#i-check"/></svg>Encrypted at rest</li>
|
||||
<li><svg class="icon" aria-hidden="true"><use href="#i-check"/></svg>One Docker image</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="hero__visual">
|
||||
<svg class="hero__shape hero__shape--one" viewBox="0 0 100 100" aria-hidden="true"><use href="#s-soft-burst"/></svg>
|
||||
<svg class="hero__shape hero__shape--two" viewBox="0 0 100 100" aria-hidden="true"><use href="#s-cookie-9"/></svg>
|
||||
<div class="device device--laptop">
|
||||
<div class="device__lid">
|
||||
<div class="device__screen">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="img/screenshots/desktop/dark/01-upload-1600.webp 1600w, img/screenshots/desktop/dark/01-upload-800.webp 800w" sizes="(max-width: 960px) 94vw, 58vw">
|
||||
<img src="img/screenshots/desktop/light/01-upload-800.webp" srcset="img/screenshots/desktop/light/01-upload-1600.webp 1600w, img/screenshots/desktop/light/01-upload-800.webp 800w" sizes="(max-width: 960px) 94vw, 58vw"
|
||||
width="1512" height="982" loading="eager" decoding="async"
|
||||
alt="SealShare's upload page with files selected and share options">
|
||||
</picture>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device device--phone">
|
||||
<div class="device__screen">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="img/screenshots/phone/dark/03-download-1080.webp 1080w, img/screenshots/phone/dark/03-download-540.webp 540w" sizes="(max-width: 960px) 24vw, 15vw">
|
||||
<img src="img/screenshots/phone/light/03-download-540.webp" srcset="img/screenshots/phone/light/03-download-1080.webp 1080w, img/screenshots/phone/light/03-download-540.webp 540w" sizes="(max-width: 960px) 24vw, 15vw"
|
||||
width="393" height="852" loading="eager" decoding="async"
|
||||
alt="The download page on a phone">
|
||||
</picture>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section section--tint" id="why" aria-labelledby="why-title">
|
||||
<div class="container why">
|
||||
<div class="why__statement">
|
||||
<span class="eyebrow"><svg class="icon" aria-hidden="true"><use href="#i-cloud-upload"/></svg>Why run your own</span>
|
||||
<h2 class="headline" id="why-title">Stop routing your customers' files through someone else's service</h2>
|
||||
<p class="lede">Transfer services are quick, but every file you exchange with a customer ends up on their servers, under their terms. SealShare is a simple setup for your own upload platform: a secure way to get data from customers and share data with them, entirely under your control.</p>
|
||||
</div>
|
||||
<div class="reasons">
|
||||
<article class="reason">
|
||||
<span class="badge-icon" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-9"/></svg><svg class="icon" aria-hidden="true"><use href="#i-dns"/></svg></span>
|
||||
<h3 class="title">Your server, your data</h3>
|
||||
<p>Files are stored on infrastructure you control, in the country you choose — not with a transfer service.</p>
|
||||
</article>
|
||||
<article class="reason">
|
||||
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-clover-4"/></svg><svg class="icon" aria-hidden="true"><use href="#i-no-accounts"/></svg></span>
|
||||
<h3 class="title">No accounts for customers</h3>
|
||||
<p>Customers open a link and download. Nobody has to sign up anywhere.</p>
|
||||
</article>
|
||||
<article class="reason">
|
||||
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-puffy-diamond"/></svg><svg class="icon" aria-hidden="true"><use href="#i-palette"/></svg></span>
|
||||
<h3 class="title">Your name on it</h3>
|
||||
<p>Your domain, and your logo, title and description on the upload page.</p>
|
||||
</article>
|
||||
<article class="reason">
|
||||
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-sunny"/></svg><svg class="icon" aria-hidden="true"><use href="#i-money-off"/></svg></span>
|
||||
<h3 class="title">No per-user pricing</h3>
|
||||
<p>Open source under the MIT licence. Use it for the whole company, without seats or subscriptions.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="how" aria-labelledby="how-title">
|
||||
<div class="container">
|
||||
<div class="section__head section__head--center">
|
||||
<h2 class="headline" id="how-title">How it works</h2>
|
||||
<p class="lede">Three steps, for you and for the people you share with.</p>
|
||||
</div>
|
||||
<ol class="steps">
|
||||
<li class="step">
|
||||
<span class="badge-icon" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-9"/></svg><svg class="icon" aria-hidden="true"><use href="#i-cloud-upload"/></svg></span>
|
||||
<h3 class="title">Upload</h3>
|
||||
<p>Drop files or whole folders. Add a password, an expiry and a download limit if you want.</p>
|
||||
</li>
|
||||
<li class="step">
|
||||
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-soft-burst"/></svg><svg class="icon" aria-hidden="true"><use href="#i-qr-code-2"/></svg></span>
|
||||
<h3 class="title">Share</h3>
|
||||
<p>Copy the link, show it as a QR code, or send it from your phone's share sheet.</p>
|
||||
</li>
|
||||
<li class="step">
|
||||
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-gem"/></svg><svg class="icon" aria-hidden="true"><use href="#i-download"/></svg></span>
|
||||
<h3 class="title">Download</h3>
|
||||
<p>Your customer opens the link and downloads single files or everything as a ZIP. Expired shares delete themselves.</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section section--tint" id="features" aria-labelledby="features-title">
|
||||
<div class="container">
|
||||
<div class="section__head">
|
||||
<span class="eyebrow"><svg class="icon" aria-hidden="true"><use href="#i-check"/></svg>Features</span>
|
||||
<h2 class="headline" id="features-title">Everything a file exchange with customers needs</h2>
|
||||
<p class="lede">And nothing that turns it into a project: SealShare runs from a single container and is set up in a few minutes. It follows your system's light or dark theme, on desktop and phone.</p>
|
||||
</div>
|
||||
<div class="features">
|
||||
<article class="feature">
|
||||
<span class="badge-icon" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-drive-folder-upload"/></svg></span>
|
||||
<h3>Files and folders</h3>
|
||||
<p>Drag & drop or browse; folders keep their structure, with live upload progress.</p>
|
||||
</article>
|
||||
<article class="feature">
|
||||
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-link"/></svg></span>
|
||||
<h3>A link per share</h3>
|
||||
<p>Every upload gets its own random, hard-to-guess link.</p>
|
||||
</article>
|
||||
<article class="feature">
|
||||
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-qr-code-2"/></svg></span>
|
||||
<h3>QR code and share sheet</h3>
|
||||
<p>Show the link as a QR code, save it as a PNG, or pass it to the phone's share sheet.</p>
|
||||
</article>
|
||||
<article class="feature">
|
||||
<span class="badge-icon" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-lock"/></svg></span>
|
||||
<h3>Password protection</h3>
|
||||
<p>Protect a share with a password; its encryption key is derived from it.</p>
|
||||
</article>
|
||||
<article class="feature">
|
||||
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-encrypted"/></svg></span>
|
||||
<h3>Encrypted at rest</h3>
|
||||
<p>Encrypted at rest with AES-256-GCM; with a share password the key is never stored.</p>
|
||||
</article>
|
||||
<article class="feature">
|
||||
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-schedule"/></svg></span>
|
||||
<h3>Expiry</h3>
|
||||
<p>From 1 hour to 30 days, or never when the admin allows it.</p>
|
||||
</article>
|
||||
<article class="feature">
|
||||
<span class="badge-icon" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-counter-5"/></svg></span>
|
||||
<h3>Download limits</h3>
|
||||
<p>Close a share after a set number of downloads.</p>
|
||||
</article>
|
||||
<article class="feature">
|
||||
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-folder-zip"/></svg></span>
|
||||
<h3>ZIP download</h3>
|
||||
<p>Recipients download everything in one archive.</p>
|
||||
</article>
|
||||
<article class="feature">
|
||||
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-delete-sweep"/></svg></span>
|
||||
<h3>Automatic clean-up</h3>
|
||||
<p>Expired shares and their files are deleted every hour.</p>
|
||||
</article>
|
||||
<article class="feature">
|
||||
<span class="badge-icon" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-dashboard"/></svg></span>
|
||||
<h3>Admin dashboard</h3>
|
||||
<p>Every share with its size, downloads and expiry; storage use at a glance.</p>
|
||||
</article>
|
||||
<article class="feature">
|
||||
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-admin-panel-settings"/></svg></span>
|
||||
<h3>Limits and quota</h3>
|
||||
<p>Maximum file and share size, files per share, storage quota, default expiry.</p>
|
||||
</article>
|
||||
<article class="feature">
|
||||
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-palette"/></svg></span>
|
||||
<h3>Your branding</h3>
|
||||
<p>Your logo, site title and description on the upload page, in one of eight colour profiles.</p>
|
||||
</article>
|
||||
<article class="feature">
|
||||
<span class="badge-icon" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-shield-lock"/></svg></span>
|
||||
<h3>System password</h3>
|
||||
<p>Optionally close the upload page to everyone who does not have the password.</p>
|
||||
</article>
|
||||
<article class="feature">
|
||||
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-phonelink-lock"/></svg></span>
|
||||
<h3>Two-factor sign-in</h3>
|
||||
<p>Admins protect their accounts with authenticator app codes.</p>
|
||||
</article>
|
||||
<article class="feature">
|
||||
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-deployed-code"/></svg></span>
|
||||
<h3>One Docker image</h3>
|
||||
<p>FrankenPHP with Laravel Octane, automatic TLS and SQLite — no separate database.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="screenshots" aria-labelledby="screenshots-title">
|
||||
<div class="container">
|
||||
<div class="section__head section__head--center">
|
||||
<h2 class="headline" id="screenshots-title">See it in action</h2>
|
||||
<p class="lede">The same interface on a laptop and a phone, in light or dark.</p>
|
||||
</div>
|
||||
<div class="gallery__controls">
|
||||
<div class="segmented" role="group" aria-label="Device">
|
||||
<button type="button" data-gallery-device="desktop" aria-pressed="true"><svg class="icon" aria-hidden="true"><use href="#i-check"/></svg>Desktop</button>
|
||||
<button type="button" data-gallery-device="phone" aria-pressed="false"><svg class="icon" aria-hidden="true"><use href="#i-check"/></svg>Phone</button>
|
||||
</div>
|
||||
<div class="segmented" role="group" aria-label="Theme">
|
||||
<button type="button" data-gallery-theme="light" aria-pressed="true"><svg class="icon" aria-hidden="true"><use href="#i-check"/></svg>Light</button>
|
||||
<button type="button" data-gallery-theme="dark" aria-pressed="false"><svg class="icon" aria-hidden="true"><use href="#i-check"/></svg>Dark</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="gallery gallery--desktop" data-gallery="desktop">
|
||||
<li>
|
||||
<figure class="shot">
|
||||
<div class="device device--laptop">
|
||||
<div class="device__lid">
|
||||
<div class="device__screen">
|
||||
<img src="img/screenshots/desktop/light/01-upload-800.webp" srcset="img/screenshots/desktop/light/01-upload-1600.webp 1600w, img/screenshots/desktop/light/01-upload-800.webp 800w" sizes="(max-width: 840px) 94vw, 568px"
|
||||
data-light-src="img/screenshots/desktop/light/01-upload-800.webp" data-light-srcset="img/screenshots/desktop/light/01-upload-1600.webp 1600w, img/screenshots/desktop/light/01-upload-800.webp 800w"
|
||||
data-dark-src="img/screenshots/desktop/dark/01-upload-800.webp" data-dark-srcset="img/screenshots/desktop/dark/01-upload-1600.webp 1600w, img/screenshots/desktop/dark/01-upload-800.webp 800w"
|
||||
width="1512" height="982" loading="lazy" decoding="async"
|
||||
alt="The upload page with two files selected and the share options filled in">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption><strong>Upload files and folders</strong><span>Drag & drop, then set a password, an expiry and a download limit.</span></figcaption>
|
||||
</figure>
|
||||
</li>
|
||||
<li>
|
||||
<figure class="shot">
|
||||
<div class="device device--laptop">
|
||||
<div class="device__lid">
|
||||
<div class="device__screen">
|
||||
<img src="img/screenshots/desktop/light/02-share-created-800.webp" srcset="img/screenshots/desktop/light/02-share-created-1600.webp 1600w, img/screenshots/desktop/light/02-share-created-800.webp 800w" sizes="(max-width: 840px) 94vw, 568px"
|
||||
data-light-src="img/screenshots/desktop/light/02-share-created-800.webp" data-light-srcset="img/screenshots/desktop/light/02-share-created-1600.webp 1600w, img/screenshots/desktop/light/02-share-created-800.webp 800w"
|
||||
data-dark-src="img/screenshots/desktop/dark/02-share-created-800.webp" data-dark-srcset="img/screenshots/desktop/dark/02-share-created-1600.webp 1600w, img/screenshots/desktop/dark/02-share-created-800.webp 800w"
|
||||
width="1512" height="982" loading="lazy" decoding="async"
|
||||
alt="The share created page with the share link, its details and the QR code button">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption><strong>Share the link</strong><span>Copy it, show it as a QR code or send it from the share sheet.</span></figcaption>
|
||||
</figure>
|
||||
</li>
|
||||
<li>
|
||||
<figure class="shot">
|
||||
<div class="device device--laptop">
|
||||
<div class="device__lid">
|
||||
<div class="device__screen">
|
||||
<img src="img/screenshots/desktop/light/03-qr-code-800.webp" srcset="img/screenshots/desktop/light/03-qr-code-1600.webp 1600w, img/screenshots/desktop/light/03-qr-code-800.webp 800w" sizes="(max-width: 840px) 94vw, 568px"
|
||||
data-light-src="img/screenshots/desktop/light/03-qr-code-800.webp" data-light-srcset="img/screenshots/desktop/light/03-qr-code-1600.webp 1600w, img/screenshots/desktop/light/03-qr-code-800.webp 800w"
|
||||
data-dark-src="img/screenshots/desktop/dark/03-qr-code-800.webp" data-dark-srcset="img/screenshots/desktop/dark/03-qr-code-1600.webp 1600w, img/screenshots/desktop/dark/03-qr-code-800.webp 800w"
|
||||
width="1512" height="982" loading="lazy" decoding="async"
|
||||
alt="The QR code dialog for a new share, with a Download button">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption><strong>A QR code for the link</strong><span>Scan it from the screen, or save it as a PNG.</span></figcaption>
|
||||
</figure>
|
||||
</li>
|
||||
<li>
|
||||
<figure class="shot">
|
||||
<div class="device device--laptop">
|
||||
<div class="device__lid">
|
||||
<div class="device__screen">
|
||||
<img src="img/screenshots/desktop/light/04-download-800.webp" srcset="img/screenshots/desktop/light/04-download-1600.webp 1600w, img/screenshots/desktop/light/04-download-800.webp 800w" sizes="(max-width: 840px) 94vw, 568px"
|
||||
data-light-src="img/screenshots/desktop/light/04-download-800.webp" data-light-srcset="img/screenshots/desktop/light/04-download-1600.webp 1600w, img/screenshots/desktop/light/04-download-800.webp 800w"
|
||||
data-dark-src="img/screenshots/desktop/dark/04-download-800.webp" data-dark-srcset="img/screenshots/desktop/dark/04-download-1600.webp 1600w, img/screenshots/desktop/dark/04-download-800.webp 800w"
|
||||
width="1512" height="982" loading="lazy" decoding="async"
|
||||
alt="The download page a recipient sees, listing the shared files">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption><strong>Your customer downloads</strong><span>Single files or everything as a ZIP, without an account.</span></figcaption>
|
||||
</figure>
|
||||
</li>
|
||||
<li>
|
||||
<figure class="shot">
|
||||
<div class="device device--laptop">
|
||||
<div class="device__lid">
|
||||
<div class="device__screen">
|
||||
<img src="img/screenshots/desktop/light/05-admin-dashboard-800.webp" srcset="img/screenshots/desktop/light/05-admin-dashboard-1600.webp 1600w, img/screenshots/desktop/light/05-admin-dashboard-800.webp 800w" sizes="(max-width: 840px) 94vw, 568px"
|
||||
data-light-src="img/screenshots/desktop/light/05-admin-dashboard-800.webp" data-light-srcset="img/screenshots/desktop/light/05-admin-dashboard-1600.webp 1600w, img/screenshots/desktop/light/05-admin-dashboard-800.webp 800w"
|
||||
data-dark-src="img/screenshots/desktop/dark/05-admin-dashboard-800.webp" data-dark-srcset="img/screenshots/desktop/dark/05-admin-dashboard-1600.webp 1600w, img/screenshots/desktop/dark/05-admin-dashboard-800.webp 800w"
|
||||
width="1512" height="982" loading="lazy" decoding="async"
|
||||
alt="The admin dashboard with share statistics, disk usage and the table of shares">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption><strong>Admin dashboard</strong><span>Every share, its size, downloads and expiry at a glance.</span></figcaption>
|
||||
</figure>
|
||||
</li>
|
||||
<li>
|
||||
<figure class="shot">
|
||||
<div class="device device--laptop">
|
||||
<div class="device__lid">
|
||||
<div class="device__screen">
|
||||
<img src="img/screenshots/desktop/light/06-admin-settings-800.webp" srcset="img/screenshots/desktop/light/06-admin-settings-1600.webp 1600w, img/screenshots/desktop/light/06-admin-settings-800.webp 800w" sizes="(max-width: 840px) 94vw, 568px"
|
||||
data-light-src="img/screenshots/desktop/light/06-admin-settings-800.webp" data-light-srcset="img/screenshots/desktop/light/06-admin-settings-1600.webp 1600w, img/screenshots/desktop/light/06-admin-settings-800.webp 800w"
|
||||
data-dark-src="img/screenshots/desktop/dark/06-admin-settings-800.webp" data-dark-srcset="img/screenshots/desktop/dark/06-admin-settings-1600.webp 1600w, img/screenshots/desktop/dark/06-admin-settings-800.webp 800w"
|
||||
width="1512" height="982" loading="lazy" decoding="async"
|
||||
alt="The admin settings page with branding and upload limits">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption><strong>Admin settings</strong><span>Upload limits, storage quota, your logo and title.</span></figcaption>
|
||||
</figure>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="gallery gallery--phone" data-gallery="phone" hidden>
|
||||
<li>
|
||||
<figure class="shot">
|
||||
<div class="device device--phone">
|
||||
<div class="device__screen">
|
||||
<img src="img/screenshots/phone/light/01-upload-540.webp" srcset="img/screenshots/phone/light/01-upload-1080.webp 1080w, img/screenshots/phone/light/01-upload-540.webp 540w" sizes="(max-width: 840px) 44vw, 232px"
|
||||
data-light-src="img/screenshots/phone/light/01-upload-540.webp" data-light-srcset="img/screenshots/phone/light/01-upload-1080.webp 1080w, img/screenshots/phone/light/01-upload-540.webp 540w"
|
||||
data-dark-src="img/screenshots/phone/dark/01-upload-540.webp" data-dark-srcset="img/screenshots/phone/dark/01-upload-1080.webp 1080w, img/screenshots/phone/dark/01-upload-540.webp 540w"
|
||||
width="393" height="852" loading="lazy" decoding="async"
|
||||
alt="The upload page on a phone">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption><strong>Upload from a phone</strong><span>The same upload page, sized for one hand.</span></figcaption>
|
||||
</figure>
|
||||
</li>
|
||||
<li>
|
||||
<figure class="shot">
|
||||
<div class="device device--phone">
|
||||
<div class="device__screen">
|
||||
<img src="img/screenshots/phone/light/02-password-540.webp" srcset="img/screenshots/phone/light/02-password-1080.webp 1080w, img/screenshots/phone/light/02-password-540.webp 540w" sizes="(max-width: 840px) 44vw, 232px"
|
||||
data-light-src="img/screenshots/phone/light/02-password-540.webp" data-light-srcset="img/screenshots/phone/light/02-password-1080.webp 1080w, img/screenshots/phone/light/02-password-540.webp 540w"
|
||||
data-dark-src="img/screenshots/phone/dark/02-password-540.webp" data-dark-srcset="img/screenshots/phone/dark/02-password-1080.webp 1080w, img/screenshots/phone/dark/02-password-540.webp 540w"
|
||||
width="393" height="852" loading="lazy" decoding="async"
|
||||
alt="The password prompt of a protected share on a phone">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption><strong>Password-protected shares</strong><span>Recipients enter the password to open a share.</span></figcaption>
|
||||
</figure>
|
||||
</li>
|
||||
<li>
|
||||
<figure class="shot">
|
||||
<div class="device device--phone">
|
||||
<div class="device__screen">
|
||||
<img src="img/screenshots/phone/light/03-download-540.webp" srcset="img/screenshots/phone/light/03-download-1080.webp 1080w, img/screenshots/phone/light/03-download-540.webp 540w" sizes="(max-width: 840px) 44vw, 232px"
|
||||
data-light-src="img/screenshots/phone/light/03-download-540.webp" data-light-srcset="img/screenshots/phone/light/03-download-1080.webp 1080w, img/screenshots/phone/light/03-download-540.webp 540w"
|
||||
data-dark-src="img/screenshots/phone/dark/03-download-540.webp" data-dark-srcset="img/screenshots/phone/dark/03-download-1080.webp 1080w, img/screenshots/phone/dark/03-download-540.webp 540w"
|
||||
width="393" height="852" loading="lazy" decoding="async"
|
||||
alt="The list of shared files on a phone">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption><strong>Download on the go</strong><span>Files and a ZIP of all of them.</span></figcaption>
|
||||
</figure>
|
||||
</li>
|
||||
<li>
|
||||
<figure class="shot">
|
||||
<div class="device device--phone">
|
||||
<div class="device__screen">
|
||||
<img src="img/screenshots/phone/light/04-qr-code-540.webp" srcset="img/screenshots/phone/light/04-qr-code-1080.webp 1080w, img/screenshots/phone/light/04-qr-code-540.webp 540w" sizes="(max-width: 840px) 44vw, 232px"
|
||||
data-light-src="img/screenshots/phone/light/04-qr-code-540.webp" data-light-srcset="img/screenshots/phone/light/04-qr-code-1080.webp 1080w, img/screenshots/phone/light/04-qr-code-540.webp 540w"
|
||||
data-dark-src="img/screenshots/phone/dark/04-qr-code-540.webp" data-dark-srcset="img/screenshots/phone/dark/04-qr-code-1080.webp 1080w, img/screenshots/phone/dark/04-qr-code-540.webp 540w"
|
||||
width="393" height="852" loading="lazy" decoding="async"
|
||||
alt="The full-screen QR code dialog on a phone">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption><strong>Scan to open</strong><span>The QR code fills the screen.</span></figcaption>
|
||||
</figure>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section section--tint" id="compare" aria-labelledby="compare-title">
|
||||
<div class="container">
|
||||
<div class="section__head">
|
||||
<span class="eyebrow"><svg class="icon" aria-hidden="true"><use href="#i-dashboard"/></svg>Compare</span>
|
||||
<h2 class="headline" id="compare-title">How SealShare compares</h2>
|
||||
<p class="lede">Hosted transfer services keep your files on their servers; other self-hosted tools do the same job in their own way. Every value below comes from the product's own website, documentation or repository.</p>
|
||||
</div>
|
||||
<div class="compare">
|
||||
<div class="compare__block">
|
||||
<h3>Hosted transfer services</h3>
|
||||
<p>Quick to use, but the files sit with the provider, under the provider's terms.</p>
|
||||
<div class="table-scroll" tabindex="0" role="region" aria-label="SealShare and hosted transfer services">
|
||||
<table>
|
||||
<caption>SealShare and hosted transfer services · As of September 2026</caption>
|
||||
<thead><tr><th scope="col"><span class="visually-hidden">Criterion</span></th><th scope="col" class="is-us">SealShare</th><th scope="col">WeTransfer</th><th scope="col">SwissTransfer</th><th scope="col">Dropbox Transfer</th><th scope="col">Google Drive links</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><th scope="row">Files stored</th><td class="is-us">Your server</td><td>WeTransfer's cloud (EU or US)</td><td>Infomaniak, Switzerland</td><td>Dropbox's cloud (US by default)</td><td>Google's cloud</td></tr>
|
||||
<tr><th scope="row">Operated by</th><td class="is-us">You</td><td>WeTransfer</td><td>Infomaniak</td><td>Dropbox</td><td>Google</td></tr>
|
||||
<tr><th scope="row">Recipient needs an account</th><td class="is-us">No</td><td>No</td><td>No</td><td>No</td><td>No, with “Anyone with the link”</td></tr>
|
||||
<tr><th scope="row">Password protection</th><td class="is-us">Yes</td><td>Yes<sup>1</sup></td><td>Yes</td><td>Standard plan and up</td><td>—</td></tr>
|
||||
<tr><th scope="row">Link expiry</th><td class="is-us">Yes, 1 hour to 30 days, or never</td><td>Up to 3 or 7 days on Free<sup>1</sup></td><td>1 to 30 days<sup>4</sup></td><td>7 days; custom on Standard and up</td><td>Work or school accounts only</td></tr>
|
||||
<tr><th scope="row">Download limit</th><td class="is-us">Yes</td><td>—</td><td>Yes, 1 to 250</td><td>—</td><td>—</td></tr>
|
||||
<tr><th scope="row">Encryption at rest</th><td class="is-us">Yes, AES-256-GCM</td><td>Yes, AES-256</td><td>Yes, AES-256</td><td>Yes, AES-256</td><td>Yes, AES-256</td></tr>
|
||||
<tr><th scope="row">End-to-end encryption</th><td class="is-us" data-compare="sealshare-e2e">No</td><td>Enterprise add-on</td><td>No</td><td>No</td><td>Higher Workspace editions</td></tr>
|
||||
<tr><th scope="row">Folder upload</th><td class="is-us">Yes</td><td>No, folders are flattened</td><td>Yes</td><td>Yes</td><td>Yes</td></tr>
|
||||
<tr><th scope="row">Custom branding</th><td class="is-us">Yes: logo, title, description</td><td>Ultimate plan and up</td><td>Through paid kDrive</td><td>Standard plan and up</td><td>—</td></tr>
|
||||
<tr><th scope="row">Maximum file size</th><td class="is-us">Set by you (server limits)</td><td>3 GB or 100 GB a month on Free<sup>1</sup></td><td>50 GB per transfer</td><td>2 GB on Basic (free)</td><td>15 GB of free storage</td></tr>
|
||||
<tr><th scope="row">Licence and cost</th><td class="is-us">MIT, free</td><td>Free (non-commercial use); Starter USD 8/month<sup>2</sup></td><td>Free</td><td>Free; Plus EUR 11.99/month<sup>2</sup></td><td>Free; 100 GB CHF 2/month<sup>2</sup></td></tr>
|
||||
<tr><th scope="row">QR code for the link</th><td class="is-us">Yes</td><td>—</td><td>—</td><td>—</td><td>—</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="compare__block">
|
||||
<h3>Self-hosted share tools</h3>
|
||||
<p>Open-source apps you run yourself, like SealShare. Included are single-purpose tools that install with Docker and had a release in the last 12 months.</p>
|
||||
<div class="table-scroll" tabindex="0" role="region" aria-label="SealShare and other self-hosted share tools">
|
||||
<table>
|
||||
<caption>SealShare and other self-hosted share tools · As of September 2026</caption>
|
||||
<thead><tr><th scope="col"><span class="visually-hidden">Criterion</span></th><th scope="col" class="is-us">SealShare</th><th scope="col">Pingvin Share X</th><th scope="col">PsiTransfer</th><th scope="col">Gokapi</th><th scope="col">Erugo</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><th scope="row">Files stored</th><td class="is-us">Your server</td><td>Your server or S3</td><td>Your server</td><td>Your server or S3</td><td>Your server</td></tr>
|
||||
<tr><th scope="row">Operated by</th><td class="is-us">You</td><td>You</td><td>You</td><td>You</td><td>You</td></tr>
|
||||
<tr><th scope="row">Recipient needs an account</th><td class="is-us">No</td><td>No</td><td>No</td><td>No</td><td>No</td></tr>
|
||||
<tr><th scope="row">Password protection</th><td class="is-us">Yes</td><td>Yes</td><td>Yes</td><td>Yes</td><td>Yes</td></tr>
|
||||
<tr><th scope="row">Link expiry</th><td class="is-us">Yes, 1 hour to 30 days, or never</td><td>Yes, up to a maximum you set</td><td>Yes, 1 hour to 8 weeks</td><td>Yes, after days or downloads</td><td>Yes, 7 days by default</td></tr>
|
||||
<tr><th scope="row">Download limit</th><td class="is-us">Yes</td><td>View limit only</td><td>One-time download only</td><td>Yes</td><td>Yes</td></tr>
|
||||
<tr><th scope="row">Encryption at rest</th><td class="is-us">Yes, AES-256-GCM</td><td>No</td><td>No</td><td>Optional</td><td>No</td></tr>
|
||||
<tr><th scope="row">End-to-end encryption</th><td class="is-us" data-compare="sealshare-e2e">No</td><td>No</td><td>No</td><td>Optional</td><td>No</td></tr>
|
||||
<tr><th scope="row">Folder upload</th><td class="is-us">Yes</td><td>Yes</td><td>No</td><td>No, files are listed singly<sup>3</sup></td><td>Yes, keeps structure</td></tr>
|
||||
<tr><th scope="row">Custom branding</th><td class="is-us">Yes: logo, title, description</td><td>Yes: logo, name, colours, CSS</td><td>Yes, by editing templates</td><td>Yes: logo, CSS, JavaScript</td><td>Yes: logo, colours, themes</td></tr>
|
||||
<tr><th scope="row">Maximum file size</th><td class="is-us">Set by you (server limits)</td><td>Set by you (1 GB per share by default)</td><td>Set by you (no limit by default)</td><td>Set by you (100 GB by default)</td><td>Set by you (2 GB per share by default)</td></tr>
|
||||
<tr><th scope="row">Licence and cost</th><td class="is-us">MIT, free</td><td>BSD-2-Clause, free</td><td>BSD-2-Clause, free</td><td>AGPL-3.0, free</td><td>MIT, free</td></tr>
|
||||
<tr><th scope="row">QR code for the link</th><td class="is-us">Yes</td><td>Yes</td><td>Yes</td><td>Yes</td><td>No</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<p class="compare__note">SealShare encrypts files on the server as they arrive. With a share password the key is never stored; without one it is kept in SealShare's database. A value marked “—” could not be confirmed from the product's own sources. Checked on 13 September 2026.</p>
|
||||
<div class="compare__notes">
|
||||
<p class="compare__note"><sup>1</sup> WeTransfer's pricing page and its help centre disagree. The pricing page lists password protection on every plan, expiry up to 7 days and up to 100 GB a month on Free; the help centre (May 2026) lists password protection under Ultimate, expiry up to 3 days, and up to 10 transfers or 3 GB in 30 days on Free.</p>
|
||||
<p class="compare__note"><sup>2</sup> The cheapest paid plan, at the price shown in Switzerland on the date above; prices differ by country. WeTransfer's Free plan is for individual, non-commercial use. Dropbox Plus has no passwords or branding for transfers; Google One 100 GB adds storage, not sharing features.</p>
|
||||
<p class="compare__note"><sup>3</sup> Gokapi's web interface lists the files of a dropped folder one by one; its command-line client uploads a folder as a ZIP file.</p>
|
||||
<p class="compare__note"><sup>4</sup> SwissTransfer's upload form offers 1, 3, 7, 15 or 30 days; Infomaniak's help articles describe 15 days, with an extension to 30 days offered to the sender by email.</p>
|
||||
</div>
|
||||
<div class="compare__block">
|
||||
<h3>Sources</h3>
|
||||
<ul class="compare__sources">
|
||||
<li><strong>WeTransfer</strong>: <a href="https://wetransfer.com/pricing" rel="noopener">Pricing</a>, <a href="https://wetransfer.com/help-center/security-privacy/how-do-we-protect-your-files" rel="noopener">How files are protected</a>, <a href="https://wetransfer.com/help-center/subscriptions/plan-limits" rel="noopener">Plan limits</a>, <a href="https://wetransfer.com/help-center/how-to/transfer-availability" rel="noopener">Transfer availability</a>, <a href="https://wetransfer.com/help-center/how-to/download-a-transfer" rel="noopener">Downloading a transfer</a>, <a href="https://wetransfer.com/help-center/how-to/send-a-transfer" rel="noopener">Sending a transfer</a>, <a href="https://wetransfer.com/enterprise" rel="noopener">Enterprise</a></li>
|
||||
<li><strong>SwissTransfer</strong>: <a href="https://www.swisstransfer.com/en" rel="noopener">SwissTransfer</a>, <a href="https://www.infomaniak.com/en/support/faq/1755/understanding-swisstransfer-data-security" rel="noopener">Data security</a>, <a href="https://www.infomaniak.com/en/support/faq/2451/getting-started-guide-swisstransfer-send-large-files-for-free" rel="noopener">Getting started</a>, <a href="https://www.infomaniak.com/en/swisstransfer-kdrive" rel="noopener">SwissTransfer with kDrive</a></li>
|
||||
<li><strong>Dropbox Transfer</strong>: <a href="https://help.dropbox.com/share/dropbox-transfer" rel="noopener">Dropbox Transfer</a>, <a href="https://help.dropbox.com/share/create-dropbox-transfer" rel="noopener">Creating a transfer</a>, <a href="https://help.dropbox.com/security/physical-location-data-storage" rel="noopener">Where data is stored</a>, <a href="https://help.dropbox.com/security/how-security-works" rel="noopener">How security works</a>, <a href="https://help.dropbox.com/security/encrypted-team-folders" rel="noopener">Encrypted team folders</a>, <a href="https://www.dropbox.com/plans" rel="noopener">Plans</a></li>
|
||||
<li><strong>Google Drive</strong>: <a href="https://support.google.com/drive/answer/2494822?hl=en" rel="noopener">Sharing files</a>, <a href="https://support.google.com/drive/answer/7166529" rel="noopener">Sharing folders</a>, <a href="https://support.google.com/drive/answer/10519333?hl=en" rel="noopener">Encryption in Drive</a>, <a href="https://support.google.com/a/answer/10741897?hl=en" rel="noopener">Client-side encryption</a>, <a href="https://support.google.com/a/answer/7630496?hl=en" rel="noopener">Data regions</a>, <a href="https://support.google.com/drive/answer/2424368?hl=en" rel="noopener">Uploading folders</a>, <a href="https://one.google.com/about/plans?hl=en" rel="noopener">Google One plans</a></li>
|
||||
<li><strong>Pingvin Share X</strong>: <a href="https://github.com/smp46/pingvin-share-x/blob/main/README.md" rel="noopener">README</a>, <a href="https://github.com/smp46/pingvin-share-x/blob/main/config.example.yaml" rel="noopener">Example configuration</a>, <a href="https://github.com/smp46/pingvin-share-x/blob/main/CHANGELOG.md" rel="noopener">Changelog</a>, <a href="https://smp46.github.io/pingvin-share-x/" rel="noopener">Documentation</a>, <a href="https://github.com/smp46/pingvin-share-x" rel="noopener">Repository</a></li>
|
||||
<li><strong>PsiTransfer</strong>: <a href="https://github.com/psi-4ward/psitransfer/blob/master/README.md" rel="noopener">README</a>, <a href="https://github.com/psi-4ward/psitransfer/blob/master/config.js" rel="noopener">Configuration</a>, <a href="https://github.com/psi-4ward/psitransfer/blob/master/docs/layout-customization.md" rel="noopener">Layout customisation</a>, <a href="https://github.com/psi-4ward/psitransfer" rel="noopener">Repository</a></li>
|
||||
<li><strong>Gokapi</strong>: <a href="https://gokapi.readthedocs.io/en/latest/index.html" rel="noopener">Documentation</a>, <a href="https://gokapi.readthedocs.io/en/latest/usage.html" rel="noopener">Usage</a>, <a href="https://gokapi.readthedocs.io/en/latest/setup.html" rel="noopener">Setup</a>, <a href="https://gokapi.readthedocs.io/en/latest/advanced.html" rel="noopener">Advanced</a>, <a href="https://github.com/Forceu/Gokapi/blob/master/LICENSE.md" rel="noopener">Licence</a></li>
|
||||
<li><strong>Erugo</strong>: <a href="https://github.com/ErugoOSS/Erugo/blob/main/README.md" rel="noopener">README</a>, <a href="https://erugo.app/docs/reference/configuration" rel="noopener">Configuration</a>, <a href="https://erugo.app/docs/guides/branding" rel="noopener">Branding</a>, <a href="https://github.com/ErugoOSS/Erugo" rel="noopener">Repository</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="install" aria-labelledby="install-title">
|
||||
<div class="container install">
|
||||
<div class="install__copy">
|
||||
<span class="eyebrow"><svg class="icon" aria-hidden="true"><use href="#i-deployed-code"/></svg>Install</span>
|
||||
<h2 class="headline" id="install-title">Running in a few minutes</h2>
|
||||
<p class="lede">All you need is a server with Docker and a domain name.</p>
|
||||
<ul class="install__list">
|
||||
<li><svg class="icon" aria-hidden="true"><use href="#i-check"/></svg><span>The image includes the web server with automatic TLS certificates, and uses SQLite — no separate database.</span></li>
|
||||
<li><svg class="icon" aria-hidden="true"><use href="#i-check"/></svg><span>Migrations run on start. Open your domain and the setup wizard creates the first admin account.</span></li>
|
||||
<li><svg class="icon" aria-hidden="true"><use href="#i-check"/></svg><span>Your files and database live in Docker volumes on your server.</span></li>
|
||||
</ul>
|
||||
<a class="btn btn--tonal" href="https://gitea.nonameweb.ch/noNameWEB/SealShare"><svg class="icon" aria-hidden="true"><use href="#i-open-in-new"/></svg>Full instructions on Gitea</a>
|
||||
</div>
|
||||
<div class="code">
|
||||
<div class="code__bar"><svg class="icon" aria-hidden="true"><use href="#i-terminal"/></svg><span>Production, with Docker Compose</span></div>
|
||||
<pre><code>mkdir sealshare && cd sealshare
|
||||
curl -O https://gitea.nonameweb.ch/noNameWEB/SealShare/raw/branch/main/docker-compose.example.yml
|
||||
cp docker-compose.example.yml docker-compose.yml
|
||||
|
||||
<span class="comment"># Generate an app key and paste it into docker-compose.yml</span>
|
||||
docker run --rm gitea.nonameweb.ch/nonameweb/sealshare:latest php artisan key:generate --show
|
||||
|
||||
<span class="comment"># Edit docker-compose.yml — set APP_KEY, APP_URL, and SERVER_NAME</span>
|
||||
<span class="comment"># Then start:</span>
|
||||
docker compose up -d</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section section--tint" id="faq" aria-labelledby="faq-title">
|
||||
<div class="container">
|
||||
<div class="section__head section__head--center">
|
||||
<h2 class="headline" id="faq-title">Frequently asked questions</h2>
|
||||
</div>
|
||||
<div class="faq">
|
||||
<details>
|
||||
<summary>Is SealShare end-to-end encrypted?<svg class="icon" aria-hidden="true"><use href="#i-expand-more"/></svg></summary>
|
||||
<div class="faq__answer">
|
||||
<p>No. SealShare encrypts files on your server as they arrive, with AES-256-GCM in chunks, and stores them only in encrypted form. Because the server does the encrypting, it handles the files in the clear while they are uploaded and downloaded — which is why it matters that the server is yours.</p>
|
||||
<p>For a share without a password the key is kept in SealShare's database. With a share password the key is derived from that password and never stored, so the files cannot be decrypted without it.</p>
|
||||
</div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Can customers send files to us?<svg class="icon" aria-hidden="true"><use href="#i-expand-more"/></svg></summary>
|
||||
<div class="faq__answer">
|
||||
<p>Yes. Anyone who can open your upload page can upload and gets a link to send back to you. To keep the page for the people you choose, turn on the optional system password in the admin settings and give it to your customers.</p>
|
||||
</div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>How big can files be?<svg class="icon" aria-hidden="true"><use href="#i-expand-more"/></svg></summary>
|
||||
<div class="faq__answer">
|
||||
<p>As big as your server allows. The Docker image accepts up to 4 GB per file by default; within that, you set the maximum file size and share size in the admin settings (100 MB and 2 GB out of the box). Behind a reverse proxy, raise its request size and timeouts as well.</p>
|
||||
</div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>What does it cost?<svg class="icon" aria-hidden="true"><use href="#i-expand-more"/></svg></summary>
|
||||
<div class="faq__answer">
|
||||
<p>Nothing. SealShare is open source under the MIT licence. You pay only for the server you run it on.</p>
|
||||
</div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Who runs it?<svg class="icon" aria-hidden="true"><use href="#i-expand-more"/></svg></summary>
|
||||
<div class="faq__answer">
|
||||
<p>You do. There is no SealShare cloud and no account with us: you install the Docker image on a server you run, and your files stay on it instead of passing through a transfer service.</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="contact" aria-labelledby="contact-title">
|
||||
<div class="container">
|
||||
<div class="contact">
|
||||
<div>
|
||||
<h2 class="headline" id="contact-title">Questions or feedback?</h2>
|
||||
<p>Get in touch, or open an issue on Gitea.</p>
|
||||
<a href="mailto:surtic86@gmail.com"><svg class="icon" aria-hidden="true"><use href="#i-mail"/></svg>surtic86@gmail.com</a>
|
||||
</div>
|
||||
<a class="btn btn--filled btn--lg" href="https://gitea.nonameweb.ch/noNameWEB/SealShare/issues"><svg class="icon" aria-hidden="true"><use href="#i-bug-report"/></svg>Open an issue</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="footer__grid">
|
||||
<div>
|
||||
<a class="brand" href="#top"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><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"/><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><span>SealShare</span></a>
|
||||
<p class="footer__blurb">Self-hosted file sharing: your company's own upload platform to exchange files with customers.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>On this site</h2>
|
||||
<ul>
|
||||
<li><a href="#features">Features</a></li>
|
||||
<li><a href="#compare">Compare</a></li>
|
||||
<li><a href="#install">Install</a></li>
|
||||
<li><a href="#faq">FAQ</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Project</h2>
|
||||
<ul>
|
||||
<li><a href="https://gitea.nonameweb.ch/noNameWEB/SealShare">Source on Gitea</a></li>
|
||||
<li><a href="https://gitea.nonameweb.ch/noNameWEB/SealShare/src/branch/main/CHANGELOG.md">Changelog</a></li>
|
||||
<li><a href="https://gitea.nonameweb.ch/noNameWEB/SealShare/src/branch/main/LICENSE">MIT licence</a></li>
|
||||
<li><a href="privacy.html">Privacy</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer__bottom">
|
||||
<span>© 2026 <a href="https://nonameweb.ch">noNameWEB</a></span>
|
||||
<span>SealShare is open-source software under the MIT licence.</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Phone menu.
|
||||
const navToggle = document.getElementById('navToggle');
|
||||
const navLinks = document.getElementById('navLinks');
|
||||
|
||||
const closeNav = () => {
|
||||
navLinks.classList.remove('is-open');
|
||||
navToggle.setAttribute('aria-expanded', 'false');
|
||||
};
|
||||
|
||||
navToggle.addEventListener('click', () => {
|
||||
const open = navLinks.classList.toggle('is-open');
|
||||
navToggle.setAttribute('aria-expanded', String(open));
|
||||
});
|
||||
|
||||
navLinks.querySelectorAll('a').forEach((link) => link.addEventListener('click', closeNav));
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
if (!event.target.closest('.nav')) {
|
||||
closeNav();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && navLinks.classList.contains('is-open')) {
|
||||
closeNav();
|
||||
navToggle.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Screenshot gallery: one device at a time, in the chosen theme (the visitor's, to begin with).
|
||||
const deviceButtons = document.querySelectorAll('[data-gallery-device]');
|
||||
const themeButtons = document.querySelectorAll('[data-gallery-theme]');
|
||||
const galleries = document.querySelectorAll('[data-gallery]');
|
||||
const shots = document.querySelectorAll('[data-gallery] img');
|
||||
|
||||
const press = (buttons, pressed) => buttons.forEach((button) => button.setAttribute('aria-pressed', String(button === pressed)));
|
||||
|
||||
const showDevice = (device) => {
|
||||
press(deviceButtons, [...deviceButtons].find((button) => button.dataset.galleryDevice === device));
|
||||
galleries.forEach((gallery) => { gallery.hidden = gallery.dataset.gallery !== device; });
|
||||
};
|
||||
|
||||
const showTheme = (theme) => {
|
||||
press(themeButtons, [...themeButtons].find((button) => button.dataset.galleryTheme === theme));
|
||||
shots.forEach((shot) => {
|
||||
shot.srcset = shot.dataset[theme + 'Srcset'];
|
||||
shot.src = shot.dataset[theme + 'Src'];
|
||||
});
|
||||
};
|
||||
|
||||
deviceButtons.forEach((button) => button.addEventListener('click', () => showDevice(button.dataset.galleryDevice)));
|
||||
themeButtons.forEach((button) => button.addEventListener('click', () => showTheme(button.dataset.galleryTheme)));
|
||||
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
showTheme('dark');
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,131 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Privacy — SealShare</title>
|
||||
<meta name="description" content="How the SealShare website handles visitor data: server logs at the host and Plausible analytics, nothing else.">
|
||||
<meta name="theme-color" content="#faf4ff" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#10062d" media="(prefers-color-scheme: dark)">
|
||||
<!-- Privacy-friendly analytics by Plausible -->
|
||||
<script async src="https://plausible.io/js/pa-zWB4R2-rrBeLfwms3uIBT.js"></script>
|
||||
<script>
|
||||
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
|
||||
plausible.init()
|
||||
</script>
|
||||
<link rel="icon" href="img/favicon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="img/icon.png">
|
||||
<link rel="preload" href="fonts/GoogleSansFlex-Latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="stylesheet" href="css/theme.css">
|
||||
</head>
|
||||
<body id="top">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="sprite" aria-hidden="true">
|
||||
<symbol id="i-menu" viewBox="0 -960 960 960"><path d="M160-240q-17 0-28.5-11.5T120-280q0-17 11.5-28.5T160-320h640q17 0 28.5 11.5T840-280q0 17-11.5 28.5T800-240H160Zm0-200q-17 0-28.5-11.5T120-480q0-17 11.5-28.5T160-520h640q17 0 28.5 11.5T840-480q0 17-11.5 28.5T800-440H160Zm0-200q-17 0-28.5-11.5T120-680q0-17 11.5-28.5T160-720h640q17 0 28.5 11.5T840-680q0 17-11.5 28.5T800-640H160Z"/></symbol>
|
||||
<symbol id="i-close" viewBox="0 -960 960 960"><path d="M480-424 284-228q-11 11-28 11t-28-11q-11-11-11-28t11-28l196-196-196-196q-11-11-11-28t11-28q11-11 28-11t28 11l196 196 196-196q11-11 28-11t28 11q11 11 11 28t-11 28L536-480l196 196q11 11 11 28t-11 28q-11 11-28 11t-28-11L480-424Z"/></symbol>
|
||||
<symbol id="i-open-in-new" viewBox="0 -960 960 960"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h240q17 0 28.5 11.5T480-800q0 17-11.5 28.5T440-760H200v560h560v-240q0-17 11.5-28.5T800-480q17 0 28.5 11.5T840-440v240q0 33-23.5 56.5T760-120H200Zm560-584L416-360q-11 11-28 11t-28-11q-11-11-11-28t11-28l344-344H600q-17 0-28.5-11.5T560-800q0-17 11.5-28.5T600-840h200q17 0 28.5 11.5T840-800v200q0 17-11.5 28.5T800-560q-17 0-28.5-11.5T760-600v-104Z"/></symbol>
|
||||
</svg>
|
||||
|
||||
<header class="nav">
|
||||
<div class="container nav__bar">
|
||||
<a class="brand" href="index.html"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><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"/><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><span>SealShare</span></a>
|
||||
<button class="nav__toggle" id="navToggle" type="button" aria-expanded="false" aria-controls="navLinks" aria-label="Menu">
|
||||
<svg class="icon icon-menu" aria-hidden="true"><use href="#i-menu"/></svg>
|
||||
<svg class="icon icon-close" aria-hidden="true"><use href="#i-close"/></svg>
|
||||
</button>
|
||||
<nav aria-label="Main">
|
||||
<ul class="nav__links" id="navLinks">
|
||||
<li><a href="index.html#why">Why</a></li>
|
||||
<li><a href="index.html#features">Features</a></li>
|
||||
<li><a href="index.html#screenshots">Screenshots</a></li>
|
||||
<li><a href="index.html#compare">Compare</a></li>
|
||||
<li><a href="index.html#install">Install</a></li>
|
||||
<li><a href="index.html#faq">FAQ</a></li>
|
||||
<li><a class="btn btn--filled" href="https://gitea.nonameweb.ch/noNameWEB/SealShare"><svg class="icon" aria-hidden="true"><use href="#i-open-in-new"/></svg>Gitea</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main" class="legal">
|
||||
<div class="container">
|
||||
<article>
|
||||
<h1 class="headline">Privacy</h1>
|
||||
<p class="meta">Last updated: September 2026</p>
|
||||
|
||||
<p>This page explains what happens to your data when you visit sealshare.nonameweb.ch. It is about this website only. SealShare itself is software that companies install and run on their own servers; this website does not host it, and files shared through a SealShare installation never reach us.</p>
|
||||
|
||||
<h2>Who runs this website</h2>
|
||||
<p>This website is run by noNameWEB, Switzerland. For any question about your data, write to <a href="mailto:surtic86@gmail.com">surtic86@gmail.com</a>.</p>
|
||||
|
||||
<h2>Hosting and server logs</h2>
|
||||
<p>The website is hosted by METANET AG in Switzerland. When you open a page, the web server records technical data in its logs, as every web server does: your IP address, the date and time, the page requested, your browser's user agent and the referring page. These logs keep the site running and secure; they are not used to identify you and are kept only as long as the host's standard retention.</p>
|
||||
|
||||
<h2>Analytics</h2>
|
||||
<p>We count visits with <a href="https://plausible.io">Plausible Analytics</a>, a privacy-friendly service hosted in the EU. Plausible uses no cookies and stores no personal data: it does not keep your IP address or build a profile of you. What we see are totals, such as page views, referring sites, countries and device types. See <a href="https://plausible.io/data-policy">Plausible's data policy</a>.</p>
|
||||
|
||||
<h2>No other third parties</h2>
|
||||
<ul>
|
||||
<li>The fonts, images and styles are served from this website. Nothing is loaded from Google or other font and content networks.</li>
|
||||
<li>There are no advertising, social media or tracking scripts besides Plausible.</li>
|
||||
<li>The website sets no cookies and has no forms.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Links to other sites</h2>
|
||||
<p>Links to Gitea, the source repository and Plausible lead to sites with their own privacy policies.</p>
|
||||
|
||||
<h2>Your rights</h2>
|
||||
<p>Under the Swiss Federal Act on Data Protection you can ask what data about you is processed, and have it corrected or deleted. Write to the address above.</p>
|
||||
|
||||
<h2>Changes</h2>
|
||||
<p>If this website starts handling data differently, this page is updated and its date changed.</p>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="footer__grid">
|
||||
<div>
|
||||
<a class="brand" href="index.html"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><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"/><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><span>SealShare</span></a>
|
||||
<p class="footer__blurb">Self-hosted file sharing: your company's own upload platform to exchange files with customers.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>On this site</h2>
|
||||
<ul>
|
||||
<li><a href="index.html#features">Features</a></li>
|
||||
<li><a href="index.html#compare">Compare</a></li>
|
||||
<li><a href="index.html#install">Install</a></li>
|
||||
<li><a href="index.html#faq">FAQ</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Project</h2>
|
||||
<ul>
|
||||
<li><a href="https://gitea.nonameweb.ch/noNameWEB/SealShare">Source on Gitea</a></li>
|
||||
<li><a href="https://gitea.nonameweb.ch/noNameWEB/SealShare/src/branch/main/CHANGELOG.md">Changelog</a></li>
|
||||
<li><a href="https://gitea.nonameweb.ch/noNameWEB/SealShare/src/branch/main/LICENSE">MIT licence</a></li>
|
||||
<li><a href="privacy.html">Privacy</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer__bottom">
|
||||
<span>© 2026 <a href="https://nonameweb.ch">noNameWEB</a></span>
|
||||
<span>SealShare is open-source software under the MIT licence.</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const navToggle = document.getElementById('navToggle');
|
||||
const navLinks = document.getElementById('navLinks');
|
||||
|
||||
navToggle.addEventListener('click', () => {
|
||||
const open = navLinks.classList.toggle('is-open');
|
||||
navToggle.setAttribute('aria-expanded', String(open));
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||