9 Commits
Author SHA1 Message Date
Andreas Reinhold / reiniandClaude Opus 5 c95d0c43c2 Load the website's Plausible site script
linter / quality (push) Successful in 1m7s
tests / ci (8.5) (push) Successful in 2m16s
docker / build-and-push (push) Successful in 7m5s
docker / test (8.5) (push) Successful in 2m25s
docker / release (push) Successful in 5s
Plausible gives each new site its own script and an init call instead
of the generic script with data-domain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 16:21:51 +02:00
Andreas Reinhold / reiniandClaude Opus 5 09e24ade14 Build the amd64 image with QEMU 9.2.2 and only emulate the final stage
docker / test (8.5) (push) Successful in 2m20s
linter / quality (push) Successful in 1m5s
tests / ci (8.5) (push) Successful in 2m16s
docker / build-and-push (push) Successful in 20m13s
docker / release (push) Skipped
QEMU 8.x crashes running x86_64 programs on an arm64 host (QEMU issue
2168), which the 8.1.5 pin walked into, and 10.2 segfaults on the runner
as well; 9.2.2 runs node, composer and install-php-extensions on the
runner's host. The Composer and npm stages now build on the build
machine's platform: their output is the same for every target, so a
multi-arch build runs them once and natively.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 15:52:58 +02:00
Andreas Reinhold / reiniandClaude Opus 5 21bea9646d Let the admin choose one of eight colour profiles
linter / quality (push) Successful in 1m3s
tests / ci (8.5) (push) Successful in 2m8s
docker / test (8.5) (push) Successful in 2m15s
docker / build-and-push (push) Failing after 7m22s
docker / release (push) Skipped
Indigo (the default), Blue, Teal, Green, Amber, Rose and Violet in the
Vibrant style and Graphite in the Neutral style are generated from
config into the stylesheet. Admin settings opens with a colour profile
card: a swatch previews the profile on the page, and Save Settings
stores it as color_profile, which AppServiceProvider hands to the
package's resolver, so every page, mail and error page wears it. An
unknown profile is refused, and a saved one that disappears falls back
to indigo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 15:06:47 +02:00
Andreas Reinhold / reiniandClaude Opus 5 27e322c352 Update Livewire Material to 1.1.0
It brings colour profiles: several generated schemes under
<html data-scheme>, a resolver for the active one, and a picker. Boost's
copy of the package guideline and skill follows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 15:06:47 +02:00
Andreas Reinhold / reiniandClaude Opus 5 ca0dfa9396 Emulate the amd64 image with QEMU 8 on the arm64 runner
docker / test (8.5) (push) Successful in 2m9s
linter / quality (push) Successful in 1m4s
tests / ci (8.5) (push) Successful in 2m10s
docker / build-and-push (push) Failing after 9m53s
docker / release (push) Has been skipped
Recent QEMU segfaults compiling PHP extensions for amd64 on the runner's
6.8 kernel (docker/buildx#3170); QEMU 8.1.5 is the version reported to
work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 14:31:45 +02:00
Andreas Reinhold / reiniandClaude Opus 5 d860a16551 Move SealShare from GitHub to Gitea only
docker / test (8.5) (push) Successful in 2m13s
linter / quality (push) Successful in 1m8s
tests / ci (8.5) (push) Successful in 2m7s
docker / build-and-push (push) Failing after 9m54s
docker / release (push) Has been skipped
The workflows live in .gitea/workflows. The Docker workflow logs in to
the Gitea container registry with REGISTRY_TOKEN (Gitea's job token cannot
publish packages yet), publishes gitea.nonameweb.ch/nonameweb/sealshare
with a registry build cache, and makes the release on Gitea with the
version's section of the changelog as its notes.

The README, the website's quick start, both compose files and the image
label point to Gitea; the changelog announces the new image name and
links Gitea. GiteaOnlyTest keeps GitHub and ghcr.io out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 13:59:07 +02:00
Andreas Reinhold / reiniandClaude Opus 5 606cc766f2 Add the SealShare website for sealshare.nonameweb.ch
docker / test (8.5) (push) Successful in 2m5s
docker / build-and-push (push) Failing after 9m38s
docker / release (push) Has been skipped
linter / quality (push) Successful in 1m8s
tests / ci (8.5) (push) Successful in 2m4s
website/ is hand-written HTML and CSS, uploaded as it is, like the
MailifySMS site: SealShare as software a company installs for its own
upload platform, how it works, features, a desktop/phone and light/dark
gallery of the generated screenshots, a dated and sourced comparison with
hosted transfer services and self-hosted tools, the Docker quick start,
FAQ and a privacy page for Plausible. Colours come from the app's scheme,
Google Sans Flex is served locally, nothing else loads from other hosts.

The README shows three screenshots and no longer calls the encryption
end-to-end. WebsiteTest guards missing files, other hosts, the screenshot
set and the encryption wording.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 12:38:49 +02:00
Andreas Reinhold / reiniandClaude Opus 5 e49bdf3eb3 Take the website's screenshots with one command
composer screenshots runs Pest browser tests in tests/Screenshots, outside
every test suite: fixed demo data under a frozen clock, desktop (MacBook
14, 2x) and phone (iPhone 15 Pro, 3x) in light and dark, each capture
published at once as WebP at two widths into website/img/screenshots.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 12:30:53 +02:00
Andreas Reinhold / reiniandClaude Opus 5 4470c2ed87 Give the dev container its own node_modules
npm installs the build tools' native binaries for the platform it runs
on, so a folder shared with the host only ever held Linux's or macOS's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 12:30:53 +02:00
87 changed files with 5962 additions and 48 deletions
+2 -2
View File
@@ -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.
+2
View File
@@ -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 |
+9
View File
@@ -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.
+9
View File
@@ -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"`.
+4 -1
View File
@@ -21,10 +21,13 @@ docker-compose*.yml
Dockerfile
# CI/CD
.github
.gitea
# Testing
tests
# Website (published separately)
website
phpunit.xml
.phpunit.cache
+1 -1
View File
@@ -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/*
+2
View File
@@ -25,3 +25,5 @@ yarn-error.log
**/caddy
frankenphp
frankenphp-worker.php
/tests/Browser/Screenshots
+5 -6
View File
@@ -11,11 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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 +30,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 +107,5 @@ 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.0...main
[2.0.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/releases/tag/v2.0.0
+1 -1
View File
@@ -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`.
+8 -5
View File
@@ -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 \
+17 -4
View File
@@ -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
+8
View File
@@ -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);
+6
View File
@@ -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'));
}
/**
+5
View File
@@ -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": {
Generated
+3 -3
View File
@@ -2999,11 +2999,11 @@
},
{
"name": "nonameweb/livewire-material",
"version": "1.0.1",
"version": "1.1.0",
"source": {
"type": "git",
"url": "https://gitea.nonameweb.ch/noNameWEB/livewire-material.git",
"reference": "7798352cfc71643a88d4658f9704e65c4e24e8da"
"reference": "d805dbc4076dc06c4d7e5023f99be4c420219975"
},
"require": {
"laravel/framework": "^13.0",
@@ -3083,7 +3083,7 @@
"material-design",
"tailwindcss"
],
"time": "2026-09-13T09:16:52+00:00"
"time": "2026-09-13T12:39:26+00:00"
},
{
"name": "nunomaduro/termwind",
+26
View File
@@ -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
+3
View File
@@ -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
+2 -2
View File
@@ -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:
+2 -2
View File
@@ -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
+210
View File
@@ -0,0 +1,210 @@
# Colour profiles
## Goal
An installation of SealShare can wear one of eight colour profiles instead of the single indigo
scheme. The admin picks the profile in Admin settings, previews it on the page while choosing, and
on Save it applies to everyone: signed-in users, recipients on the upload and download pages, the
Markdown mails and the error pages. Livewire Material learns colour profiles in general — any
application lists its own in config, the package generates them, switches between them before the
first paint and follows the active one everywhere it draws colour — and ships it as 1.1.0 before
SealShare tags 2.0.0.
## Context
**Livewire Material 1.0.1** (`../livewire-material`):
- `php artisan material:scheme {seed} --variant= --contrast= --success= --warning= --info= --output=`
(`src/Console/SchemeCommand.php`) runs `resources/node/scheme.mjs` (Google's
material-color-utilities, a 93 KB bundle) through Node and writes `resources/css/material-scheme.css`
`:root, [data-theme='light'] { color-scheme: light; --md-sys-color-*: … }` and
`[data-theme='dark'] { … }`, about 60 roles each — and `material-scheme.json`
(`{seed, variant, spec, contrast, light, dark}`).
- Every component and token reads only `--md-sys-color-*` (`resources/css/tokens/theme.css` maps them
to Tailwind colours). The package's own default is `resources/css/tokens/scheme.css` and `.json`.
- `<x-theme-script>` (in `<head>`, before `@vite`) writes `data-theme`, `data-theme-choice`,
`data-theme-key`, `data-rail`, `data-rail-key` on `<html>` before the first paint, and puts them
back after a `wire:navigate` swap (`onSwap`). `$store.theme` lives in `resources/js/theme.js`.
- `Support\Scheme::load()` / `light()` read `config('livewire-material.scheme')` (the JSON) merged over
the package default; the mail theme (`resources/views/mail/theme.blade.php`) and the fallback styles
of the error pages (`Support\ErrorPage::fallbackStyles()`) use it.
- Tests: `SchemeCommandTest` (runs Node), `TokensTest`, `MailThemeTest`, `ErrorPagesTest`,
`ShowcaseTest`, browser tests in three engines; CI on Gitea.
**SealShare:**
- One scheme, `#4f46e5` Vibrant (`.ai/rules/css.md` records the exact command).
- The production Docker image has no Node, so nothing can be generated at runtime.
- Settings → Appearance is the Light/Dark/System picker, stored per browser. All users are admins;
recipients are guests.
- `App\Livewire\Admin\AdminSettings` holds the settings in `Setting` (key/value) and saves them in
`saveSettings()` with one validation call; the form ends in "Save Settings". It uses `Toasts`.
- Octane: the application boots once per worker, so anything request-specific must be read per call.
## Decisions
- **The admin chooses, nobody else** — one profile for the whole installation; no per-user or
per-visitor choice. Light, dark and system stay each visitor's own, as now.
- **Ready-made profiles, no free colour** — generated ahead of time with `material:scheme` and shipped
in the CSS: correct from the first frame, no generator in the browser, mails and error pages can
follow.
- **Eight profiles, Vibrant style like today**`indigo` Indigo `#4f46e5` (the default, today's),
`blue` Blue `#0b57d0`, `teal` Teal `#00897b`, `green` Green `#2e7d32`, `amber` Amber `#e8710a`,
`rose` Rose `#c2185b`, `violet` Violet `#6750a4`, all `vibrant`; `graphite` Graphite `#5f6368`
in the `neutral` style.
- **The mechanism is the package's, the profiles are the application's** — Livewire Material gets
`profiles` in its config; SealShare lists its eight in its published config. Other applications
define their own.
- **Swatch picker at the top of Admin settings, previewed live, applied on Save** — a "Colour
profile" card with one swatch per profile (primary, secondary and tertiary dots, the name, a check
on the chosen one); a click recolours the page at once; "Save Settings" stores it for everyone.
Leaving without saving shows the saved profile on the next page.
- **Profiles are keyed by `<html data-scheme>`** — the default profile also stands without the
attribute, so the stylesheet works before the head script runs and with an unknown name:
```css
:root, [data-theme='light'] { /* default, light */ }
[data-theme='dark'] { /* default, dark */ }
[data-scheme='teal'], [data-scheme='teal'][data-theme='light'] { /* teal, light */ }
[data-scheme='teal'][data-theme='dark'] { /* teal, dark */ }
```
A profile's two-attribute selectors outrank the default's single ones, and its one-attribute
selector comes later in the file than `:root`, so the order is part of the format.
- **The active profile is resolved on every use, never kept** — the application registers a resolver
once (`Scheme::resolveProfileUsing(fn (): ?string => …)`); the head script, the mail theme and the
error pages call it each time they draw. A name that is not a generated profile, or no resolver,
falls back to the JSON's `default` — the `profile` config (else the first profile) when the scheme
was generated. Nothing request-specific is
stored on a static, so Octane workers stay clean.
- **The JSON keeps its old top-level shape**`light` and `dark` are still the default profile's
roles, beside `default` and `profiles.{name}.{label, seed, variant, spec, contrast, light, dark}`,
so a reader of the 1.0 format keeps working.
- **`material:scheme` with a seed is unchanged** — one scheme, as in 1.0. Without a seed it generates
every configured profile; without either it fails with a message naming both ways.
- **A `<x-scheme-picker>` component in the package** — native radios in a `radiogroup`, bound with
`wire:model` (or `x-model`), each labelled with the profile's name and its three colours from the
JSON; choosing one sets `<html data-scheme>` immediately (the preview). Errors for the bound property
show under it.
- **The showcase can preview every profile** — a profile menu in its app bar when profiles are
configured, recolouring the showcase without storing anything.
- **Release** — Livewire Material 1.1.0 (a feature), then SealShare's lock, all before 2.0.0.
SealShare's changelog lists it under 2.0.0 "Added".
## Out of scope
- A colour picker for any colour, extracting a colour from the logo, or per-profile contrast levels.
- Per-user or per-visitor profiles, or a profile switch outside Admin settings.
- Changing the website's colours (it stays indigo) or adding profile screenshots.
- New success/warning/info sources per profile — they stay the package defaults.
## Implementation steps
### Livewire Material 1.1.0 (`../livewire-material`)
1. **Config.** `config/livewire-material.php`: `'profiles' => []` (name ⇒ `label`, `seed`,
`variant`, optional `contrast`) and `'profile' => null` (the fallback name), documented in the
config comment beside `scheme`.
2. **Generator.** `SchemeCommand`: `seed` becomes optional. Without it, read `profiles`; for each
run `scheme.mjs` as today (validating seed, variant and contrast through the generator's own
errors), then write the stylesheet in the format under Decisions — the default profile (the
`profile` config, else the first) as the plain blocks, then every profile's blocks in config
order — and the JSON with `default`, `profiles` and the default's top-level `light`/`dark`. The
header comment names the command and says the profiles come from config. With neither a seed nor
profiles, fail naming both.
3. **Scheme.** `Support\Scheme`: `resolveProfileUsing(?Closure $resolver): void`,
`profiles(?string $path = null): array` (name ⇒ label and light/dark roles, from the JSON),
`profile(?string $path = null): ?string` (the resolver's answer if it names a profile in the
JSON, else `default` from the JSON, else null), and `load(?string $path = null, ?string $profile = null)`
returning that profile's roles merged over the package default (the active profile when
`$profile` is null; the top-level roles for a 1.0 file). `light()` follows, so the mail theme and
`ErrorPage::fallbackStyles()` draw the active profile without further change: the fallback's plain
`:root`/`[data-theme]` blocks carry that profile's roles, which is all a page without its build
needs. Every method reads the JSON on each call, as `load()` does today.
4. **Head script.** `<x-theme-script>`: when the JSON has profiles, write
`data-scheme="{active profile}"` on `<html>` with the others, and keep it through `onSwap`.
`$store.theme` gains `scheme` (read from the attribute) and `previewScheme(name)` (sets the
attribute, stores nothing).
5. **Picker.** `resources/views/components/scheme-picker.blade.php` as under Decisions: props
`label`, `hint`, `profiles` (default `Scheme::profiles()`), `name`; labels through `__()`. Each
swatch is a label around a visually hidden native radio, drawn with Tailwind utilities (a
`surface-container` tile, `outline` when checked, a check icon); its three dots are the only inline
styles — `background-color` from that profile's light roles, which `Scheme` has already checked
are `#rrggbb` — because they show another profile's colours than the page's. `x-on:change` calls
`$store.theme.previewScheme($event.target.value)`. With no profiles it renders nothing.
6. **Showcase.** A profile menu in `resources/views/showcase/layout.blade.php`'s app bar when profiles
exist, calling `previewScheme`; the colour section already reads the variables, so it follows.
`src/Showcase/Sections.php` gains the picker as an example (and the search index with it).
7. **Docs.** `resources/boost/skills/livewire-material-development/SKILL.md` (Colour scheme: profiles,
resolver, picker; the new component in Components), `resources/boost/guidelines/core.blade.php`
(one line), `README.md` (Colour scheme and Configuration).
8. **Release.** Verify in `.verify` (Feature + Browser in chrome, firefox, safari), push, watch CI,
tag `1.1.0`.
### SealShare
9. **Package.** `composer update nonameweb/livewire-material` to 1.1.0.
10. **Profiles.** `config/livewire-material.php`: the eight profiles under Decisions and
`'profile' => 'indigo'`. Run `php artisan material:scheme` to regenerate
`resources/css/material-scheme.css` and `.json`; `npm run build`. Update `.ai/rules/css.md`: the
scheme is regenerated with `php artisan material:scheme` from the profiles in config, never
hand-edited.
11. **Resolver.** `AppServiceProvider::boot()`: `Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile'))`.
12. **Admin settings.** `AdminSettings`: `public string $colorProfile`, mounted from
`Scheme::profile()`; validated with `Rule::in(array_keys(Scheme::profiles()))` in
`saveSettings()` — the profiles actually generated into the stylesheet, not merely listed in
config; saved with `Setting::set('color_profile', $this->colorProfile)`.
`admin-settings.blade.php`: a "Colour profile" card first in the form with
`<x-scheme-picker wire:model="colorProfile" :label="__('Colour profile')" />` and a hint that
the choice applies to every page, mail and error page after saving.
13. **Docs.** README features: "Colour Profiles — eight colour profiles, chosen by the admin".
CHANGELOG `2.0.0` "Added". `composer screenshots` again (the admin settings shot shows the new
card); the website's feature list gains the same line.
## Testing
**Package**
- `SchemeCommandTest`: with profiles configured and no seed, the JSON has `default` and every profile
with light and dark roles, top-level `light`/`dark` equal the default's; the stylesheet has the
default's plain blocks first and each profile's `[data-scheme='…']` blocks after, with its hexes;
a seed still writes the 1.0 format; neither fails with the message.
- `SchemeTest` (new, Feature): `profile()` follows a resolver naming a profile, falls back on an
unknown name, on no resolver and on a 1.0 file; `load()` returns the chosen profile's roles.
- `MailThemeTest`: the mail's primary is the resolved profile's. `ErrorPagesTest`: the fallback
styles carry the resolved profile's roles.
- Components: `<x-theme-script>` renders `data-scheme` for the resolved profile and none without
profiles; `<x-scheme-picker>` renders a radio per profile, checked from `wire:model`, with the
labels.
- Browser (three engines): `--md-sys-color-primary` on `<html>` is the profile's in light and in
dark, and the default's without the attribute; choosing a swatch changes it at once; the attribute
survives `wire:navigate`; the showcase menu previews a profile.
**SealShare**
- `AdminSettingsTest`: a valid profile is saved and a toast dispatched; an unknown one fails
validation and saves nothing.
- `ColourProfileTest` (new, Feature): a guest's upload page renders `data-scheme` from the saved
setting and the default without one; the reset-password mail uses the profile's primary.
- `tests/Browser/SealShareTest.php`: in Admin settings a swatch recolours the page before saving;
after Save and a reload, and on a guest's download page, the profile stays.
- `DesignLanguageTest` and `WebsiteTest` keep passing.
## Risks and open questions
- **Stylesheet size.** Eight profiles × two themes × ~60 roles is about 60 KB before compression
(a few KB gzipped); acceptable, and the CSS stays cacheable.
- **A query per page for the setting.** `Setting::get('color_profile')` runs when the head script
renders, like the site title already does; cache it later if it ever shows.
- **Swatch colours are inline styles.** The design guard does not look at `style` attributes, so
nothing stops them spreading; they stay inside `<x-scheme-picker>` and come only from `Scheme`'s
checked hexes, which the component test asserts.
- **A profile removed from config** while saved leaves the setting pointing nowhere; the resolver's
fallback to the default covers it, and Admin settings shows the default as chosen.
- **Config and stylesheet out of step.** A profile added to config but not generated is not offered:
the picker, the resolver and the validation all read the generated JSON. `.ai/rules/css.md` says to
regenerate after changing profiles.
- **Open tabs** keep the profile they loaded (or previewed) until their next full load;
`wire:navigate` carries the page's current attribute forward.
- **Error pages without a build** use the fallback styles, which draw the active profile directly;
covered by `ErrorPagesTest`.
+257
View File
@@ -0,0 +1,257 @@
# Screenshots and the SealShare website
## Goal
Two things that feed each other. First, one command — `composer screenshots` — produces every
screenshot of SealShare from fixed demo data, desktop and phone, light and dark, ready for the web.
Second, a static website at **sealshare.nonameweb.ch**, made the way mailifysms.nonameweb.ch is:
hand-written HTML and CSS in `website/`, uploaded by hand. The site presents SealShare as what it
is — software a company installs to run **its own upload platform**, so it exchanges files with
customers securely without relying on an outside service — shows the screenshots, compares
SealShare with hosted transfer services and with other self-hosted tools, and tells how to install
it. It goes live with 2.0.0. The README gets a few of the same screenshots, and its encryption
wording is corrected.
## Context
**MailifySMS, the model** (`../MailifySMS`):
- `website/` holds `index.html`, `privacy_policy.html`, `terms_and_conditions.html`,
`css/theme.css` (a palette sampled from the app's screenshots), `css/device-frame.css` (a phone
bezel shared with the store canvases), self-hosted Poppins (`fonts/`, OFL) and `img/`
(`icon.png`, `hero.jpg`, `screenshots/{light,dark}/NN-name.png` at 540px).
- `index.html`: sticky nav with a phone toggle, hero, "How it works", "Key features", a screenshot
gallery with a Light/Dark switch (`data-light`/`data-dark` on each `<img>`), FAQ accordion,
contact card (`surtic86@gmail.com`), footer (quick links, legal). Plausible:
`<script defer data-domain="mailifysms.nonameweb.ch" src="https://plausible.io/js/script.js">`.
The page's JS is one inline `<script>` at the end.
- `CLAUDE.md` records that `website/` "is a faithful copy of what is deployed, images included, so
it can be uploaded wholesale". There is no deploy automation.
- `tools/screenshots.sh` (macOS only) drives an emulator and headless Chrome; documented in
`CLAUDE.md` § Screenshots, with the reasons behind each quirk.
**Hosting.** `*.nonameweb.ch` is a wildcard DNS record to `80.74.140.2` (METANET shared hosting,
nginx), the same as mailifysms. `sealshare.nonameweb.ch` resolves already; HTTP serves the host's
placeholder, HTTPS has no certificate. Creating the site and its Let's Encrypt certificate is done
in the hosting panel.
**SealShare.**
- Laravel 13.31, Livewire 4.4, Livewire Material 1.0.1, Pest 5.1 with `pestphp/pest-plugin-browser`
(Playwright 1.63). The browser tests run the app in-process, so factories, `Storage::fake()` and
`travelTo()` shape what the browser sees. `tests/Pest.php` applies `Tests\TestCase` and
`RefreshDatabase` to `Feature` and `Browser`, and creates an admin in `beforeEach` (the setup
gate).
- Device presets: `visit()->on()->macbook14()` is 1512×982 at 2× (a 3024×1964 capture);
`on()->iPhone15Pro()` is 393×852 at 3× (1179×2556). `inLightMode()` / `inDarkMode()`,
`screenshot(fullPage, filename)`. Screenshots are written to `tests/Browser/Screenshots/<name>.png`;
the directory is created, subdirectories in the name are not — names must be flat. Pest empties
that directory when a browser run starts, and has no reduced-motion emulation.
- Pest's in-process server does not store a multipart upload, so a browser test cannot select files
through the file input.
- PHP here has GD with WebP and PNG support; the production image does not need it (this is a
development tool).
- Colours: `resources/css/material-scheme.json` (seed `#4f46e5`, Vibrant) holds the light and dark
roles as hexes. Font: Google Sans Flex, `vendor/nonameweb/livewire-material/resources/fonts/google-sans-flex/GoogleSansFlex-Latin.woff2`
with its `OFL.txt`. Logo: `resources/views/components/app-logo-icon.blade.php` (SVG).
- **Encryption, as the code does it:** `ShareService::createShare()` encrypts each uploaded file on
the server with AES-256-GCM (chunked) through `FileEncryptionService`. Without a share password
the key is stored in `shares.encryption_key`; with one, the key is derived with PBKDF2-SHA256 and
never stored. The server sees the plaintext while uploading and downloading. The README calls
this "End-to-End Encryption", which it is not.
- The upload page is public, optionally behind the system password (`SystemPasswordGate`); a
customer given that password can upload and send the link back.
- Install today (README): `ghcr.io/surtic86/sealshare`, clone from GitHub. Gitea
(`gitea.nonameweb.ch/noNameWEB/SealShare`) is now the public repository; the image registry for
2.0.0 is settled separately.
- `.gitignore` does not ignore `tests/Browser/Screenshots`; `.dockerignore` excludes `tests` and
`*.md` but would copy a `website/` directory into the image.
**Peers.** Pingvin Share has been archived since June 2025 (its README points to forks such as
Pingvin Share X). PsiTransfer, Gokapi and Erugo are single-purpose self-hosted share tools; Gokapi
advertises end-to-end encryption.
## Decisions
- **Positioning: software you host, not a service** — the site says plainly that SealShare is not
hosted by anyone but the company that installs it: its own upload platform for exchanging files
with customers, data on its own server, no dependence on an external service.
- **Pages: `index.html` and `privacy.html`** — one landing page, and a short privacy page because
the site uses Plausible. No terms page: the software is MIT-licensed and no service is offered.
- **A comparison with hosted transfer services and with self-hosted share tools** — two tables on
the landing page. Cloud suites (Nextcloud-style) are left out.
- **Hand-written HTML and CSS, like MailifySMS** — no build step; `website/` is uploaded as it is.
- **Colours copied from `material-scheme.json`, not sampled**`website/css/theme.css` lists the
roles it uses with the scheme's hexes, light by default and dark under
`@media (prefers-color-scheme: dark)`; it names the seed and variant it was copied from, so a
regenerated scheme is copied again. The site follows the visitor's system theme and has no
toggle of its own.
- **Google Sans Flex, self-hosted** — the app's font, copied with its `OFL.txt` into
`website/fonts/`; nothing is loaded from Google.
- **Uploaded by hand, like MailifySMS**`website/` is a faithful copy of what is live. You create
the subdomain and certificate once in the hosting panel and upload the folder when it changes.
No hosting credentials anywhere in the repository or CI.
- **Plausible**`data-domain="sealshare.nonameweb.ch"`, the same script as MailifySMS; the site
must be added in the Plausible account.
- **English only.**
- **Screenshots: desktop and phone, each in light and dark (20 images)**
desktop (MacBook 14, 2×): `01-upload` (files selected, options filled), `02-share-created`,
`03-qr-code` (the dialog), `04-download` (the recipient's file list), `05-admin-dashboard`,
`06-admin-settings`; phone (iPhone 15 Pro, 3×): `01-upload`, `02-password` (the recipient's
password prompt), `03-download`, `04-qr-code`.
- **Screenshots run as Pest browser tests in `tests/Screenshots/`, started by `composer screenshots`**
— the directory is not one of phpunit.xml's test suites, so `php artisan test`, the Browser
suite and CI never run it. It reuses Playwright and the in-process server.
- **Fixed demo data** — factories and `ShareService` with fixed names, sizes and tokens, time
frozen with `travelTo()`, the site title and branding at their defaults, one admin
("Alex Morgan"). Every run produces the same images unless the UI changed.
- **Images published as WebP by the test run itself** — after each capture a small helper resizes
it with GD into `website/img/screenshots/{desktop,phone}/{light,dark}/NN-name-<width>.webp` at two widths
(desktop 1600 and 800 px, phone 1080 and 540 px) for `srcset`. Raw PNGs stay in
`tests/Browser/Screenshots/`, which is gitignored; only the WebP files are committed.
- **Device frames in CSS**`website/css/device-frame.css` draws a laptop and a phone around the
screenshots; the hero shows the desktop upload and the phone download screenshots framed, in
the visitor's theme. No generated hero image.
- **README shows three screenshots** — desktop upload, desktop share created, phone download (light),
referenced from `website/img/screenshots/…`, so each image exists once in the repository.
- **Encryption is described accurately, on the site and in the README** — "encrypted at rest with
AES-256-GCM; with a share password the key is never stored". The comparison marks end-to-end
encryption "no" for SealShare. The README's "End-to-End Encryption" line is corrected.
- **Built on `material`, live with 2.0.0** — screenshots show the 2.0.0 interface; the site links
the Gitea repository, and its install commands are the README's at release, whatever registry
2.0.0 ships with.
- **Comparison facts are researched, dated and sourced** — from each product's own site,
documentation or repository; the tables say "as of <month year>" and link every source. Hosted
services: WeTransfer, SwissTransfer, Dropbox Transfer, Google Drive links. Self-hosted tools:
open source, single-purpose, installable with Docker, with a release in the 12 months before the
research — expected Pingvin Share X, PsiTransfer, Gokapi and Erugo; any that fails the rule is
dropped and named in the commit message. Criteria (rows): where files are stored, who operates
it, recipient needs an account, password protection, expiry, download limit, encryption at rest,
end-to-end encryption, folder upload, custom branding, maximum file size, licence and cost. A
value that cannot be sourced is "—", never guessed.
## Out of scope
- A terms page, a documentation section, a German version.
- Deploy automation (Gitea Actions, SFTP scripts), and creating the subdomain, certificate or
Plausible site — those are yours in the hosting panel and the Plausible account.
- Comparing with Nextcloud, ownCloud or other cloud suites.
- Store-style canvases with captions, a generated hero image, video or animated screenshots.
- Running the screenshots in CI, or checking them against earlier runs (visual regression).
- The image registry move and the install commands' final form (settled with 2.0.0).
- Implementing end-to-end encryption.
## Implementation steps
1. **Housekeeping.** `.gitignore`: `/tests/Browser/Screenshots`. `.dockerignore`: `website`.
2. **Screenshot helper.** `tests/Screenshots/Publisher.php` (`Tests\Screenshots\Publisher`):
`publish(string $capture, string $device, string $theme, string $name, array $widths): void`
reads `tests/Browser/Screenshots/<capture>.png` with GD, and for each width writes
`website/img/screenshots/<device>/<theme>/<name>-<width>.webp` (quality 82, aspect kept,
directories created). It throws when the capture is missing, so a failed shot fails the run.
3. **Demo data.** `tests/Screenshots/DemoData.php`: `admin()`, `shares()` (eight shares with fixed
tokens, file names such as `Q3-report.pdf`, `Contract 2026.pdf`, `Product photos/…`, sizes,
download counts and expiries, one password-protected, one expired), created through factories
and `ShareService` with `Storage::fake('shares')` so the files exist encrypted.
4. **Screenshot tests.** `tests/Screenshots/ScreenshotsTest.php`, with `tests/Pest.php` extended to
`->in('Feature', 'Browser', 'Screenshots')`:
- `beforeEach`: `config(['session.driver' => 'file'])`, `travelTo('2026-10-01 09:30')`, demo
data; a `ready()` wait as in `SealShareTest`.
- One test per device and theme (four tests), each visiting the pages in turn, waiting for
`networkidle` and fonts (`document.fonts.ready`), hiding the text caret, capturing
viewport-sized (not full-page) shots, and calling `Publisher::publish()` right after each.
- The upload shot with files selected: create Livewire temporary uploads on the fake
`livewire-tmp` disk and set the uploader's property through `$wire.$set` with
`livewire-file:` references, then fill the options. If Livewire refuses that, the shot shows
the drop zone with the options filled instead, and the plan's risk note is updated.
- QR dialog: `click('[data-test="show-qr-code"]')`; password prompt: the protected share on the
phone; admin pages as the admin.
5. **Command.** `composer.json` script `"screenshots"`: `Composer\\Config::disableProcessTimeout`,
`npm run build`, `@php vendor/bin/pest tests/Screenshots` — the build first, so the shots show
the current assets. Playwright's Chromium must be installed (`npx playwright install chromium`),
as for the browser tests.
6. **Website scaffold.** `website/`:
- `css/theme.css` — tokens copied from `material-scheme.json` (with seed and variant noted),
Google Sans Flex `@font-face`, layout, nav, hero, sections, cards, tables (scrolling
sideways on a phone), FAQ (`<details>`), footer; light and dark through
`prefers-color-scheme`.
- `css/device-frame.css` — laptop and phone frames.
- `fonts/GoogleSansFlex-Latin.woff2`, `fonts/OFL.txt`; `img/logo.svg` (from `app-logo-icon`),
`img/icon.png` (favicon, from `public/`).
7. **Landing page.** `website/index.html` (Plausible in `<head>`, one inline script at the end):
- nav: Why, Features, Screenshots, Compare, Install, FAQ, Gitea;
- hero: "Your own secure upload platform" — self-hosted file exchange with customers, no
outside service; buttons "Install" (to #install) and "Source on Gitea"; framed desktop and
phone screenshots as `<picture>` elements whose `<source media="(prefers-color-scheme: dark)">`
picks the dark captures;
- "Why run your own": your server, your domain and branding, customers upload and download
without accounts, encrypted at rest, no per-seat pricing;
- "How it works": upload → link or QR code → the customer downloads, with expiry, download
limit and password;
- features (from the README, accurate encryption wording);
- screenshots: Desktop/Phone and Light/Dark switches over one gallery (Light/Dark starting on
the visitor's system theme), `srcset` for both widths, `loading="lazy"`, descriptive `alt`;
- compare: the two dated tables with sources (step 8);
- install: the README's Docker quick start and a link to the full instructions on Gitea;
- FAQ: "Is it end-to-end encrypted?" (no — at rest, and what a password adds), "Can customers
send files to us?" (yes, through the upload page, optionally behind the system password),
"How big can files be?" (the README's large-file limits), "What does it cost?" (MIT, your
hosting), "Who runs it?" (you);
- contact (`surtic86@gmail.com`, as MailifySMS) and footer (Gitea, licence, privacy, noNameWEB).
8. **Comparison research.** For each product, record every criterion with its source URL and the
date checked; apply the self-hosted selection rule; fill the tables. Keep the notes in the
commit message, not in the repository.
9. **Privacy page.** `website/privacy.html`: who runs the site (contact), the host (METANET, server
logs), Plausible (cookieless, no personal data, EU-hosted, link to its data policy), no other
third parties, fonts served locally, contact for questions; dated.
10. **README and changelog.** Correct the encryption lines (the intro sentence stays accurate;
"End-to-End Encryption" becomes "Encryption at Rest", described as in Decisions), add a
Screenshots section with the three images, add the website link. CHANGELOG `2.0.0` "Fixed":
the README no longer calls the encryption end-to-end. The website and the screenshot tooling
get no changelog entry — they do not change the application.
11. **Project notes.** `record-rule` for `website/**`: `website/` is a faithful copy of what is live,
uploaded by hand; its colours are copied from `material-scheme.json` and must be copied again
when the scheme is regenerated; the comparison is dated and every value sourced. And for
`tests/Screenshots/**`: run with `composer screenshots` whenever the interface changes, before
a release; the demo data is fixed so runs are reproducible.
## Testing
- `tests/Unit/ScreenshotPublisherTest.php`: a generated PNG is written as WebP at each requested
width with the aspect ratio kept, into the device/theme directory; a missing capture throws.
- `tests/Feature/WebsiteTest.php` guards `website/` without a browser:
- every local `src`, `href`, `srcset` entry and CSS `url()` resolves to a file in `website/`;
- every screenshot the gallery or README references exists for both widths and both themes;
- no request goes to a host other than `plausible.io` (no Google Fonts, no CDN);
- `index.html` and `privacy.html` have a `<title>`, `lang="en"` and a meta description;
- the README has no "End-to-End Encryption" line, the site's features section does not say
"end-to-end", and SealShare's end-to-end cell in the comparison (marked
`data-compare="sealshare-e2e"`) reads "No" — the FAQ may still ask the question.
- The screenshot run itself is the test of step 4: it fails when a page, selector or capture
breaks. It is run by hand before a release, not in CI.
- The site is looked at in Chrome, Firefox and Safari, light and dark, at phone width, before
uploading.
## Risks and open questions
- **Selecting files in the upload shot** works: the files are stored with Livewire's own
`FileUploadConfiguration::storeTemporaryFile()` and handed to `_finishUpload` by their signed
names; Livewire's temporary-upload cleanup is turned off for the run, because under the frozen
clock it deletes them.
- **Comparison accuracy and fairness.** Other products change; the tables are dated and sourced,
and re-checked when the site is updated. Swiss unfair-competition law expects comparisons to be
accurate and not misleading — values that cannot be sourced stay "—".
- **Install commands depend on the registry move.** Until it is settled, the install section copies
the current README; it is updated before the site goes live with 2.0.0.
- **Screenshot determinism.** Relative dates ("in 3 days") depend on `travelTo()`; animations
(the share-created shape, counting stats, dialog entry) are waited out — Pest has no
reduced-motion emulation — by waiting on `document.getAnimations().length === 0` before each
capture.
- **The host's name in the privacy page** (METANET) is inferred from the server's reverse DNS
(`urbanus.ch-meta.net`); confirm it before the page goes live.
- **The contact address** is the one MailifySMS publishes (`surtic86@gmail.com`); change it in
step 7 if SealShare should have its own.
- **Image weight.** Twenty screenshots at two widths as WebP should stay under ~4 MB in total; if
not, lower the quality or drop the larger phone width.
- **Colours drift** when the scheme is regenerated; the rule in step 11 and the note in
`theme.css` are the guard.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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">
+24
View File
@@ -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();
});
+29
View File
@@ -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();
});
+36
View File
@@ -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']");
});
+44
View File
@@ -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');
});
+130
View File
@@ -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");
}
});
+1 -1
View File
@@ -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');
/*
|--------------------------------------------------------------------------
+89
View File
@@ -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();
}
}
+56
View File
@@ -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);
}
}
}
+153
View File
@@ -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']);
+32
View File
@@ -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');
+115
View File
@@ -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%;
}
File diff suppressed because it is too large Load Diff
Binary file not shown.
+91
View File
@@ -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.
+7
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+6
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+717
View File
@@ -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&#x27;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&#x27;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 &mdash; 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&#x27;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 &mdash; 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 &mdash; 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 &ldquo;Anyone with the link&rdquo;</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>&mdash;</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>&mdash;</td><td>Yes, 1 to 250</td><td>&mdash;</td><td>&mdash;</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>&mdash;</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>&mdash;</td><td>&mdash;</td><td>&mdash;</td><td>&mdash;</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 &ldquo;&mdash;&rdquo; 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 &mdash; 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 &amp;&amp; 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 &mdash; 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>&copy; 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>
+131
View File
@@ -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>&copy; 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>