A 6 GB upload kept a customer waiting long after its progress bar
reached 100%. The server wrote every upload three times: PHP's
temporary file, Livewire's copy of it ("Processing files...") and the
encrypted file ("Create Share Link"), each a full rewrite of a slow
disk. The unencrypted copy also stayed behind in livewire-tmp.
Now the uploader's browser encrypts each file in 16 MB chunks with
WebCrypto and PUTs them one at a time; the server checks each chunk in
memory and writes it once, already encrypted. Creating the share only
wraps its key and saves the options. A 200 MB upload through the
Docker image took 2.8 s, and its download matched byte for byte.
- SEALCHK2: a 19-byte header (chunk size, 7-byte nonce prefix), then
ciphertext and tag per chunk. Each nonce holds the chunk index and a
last-chunk flag (the STREAM construction), so cut or reordered files
fail to decrypt. SEALCHK1 and the single-block format still read.
- Envelope encryption: one random key per share. With a password it is
wrapped with Argon2id (sodium, libsodium's interactive limits) in
shares.wrapped_key, which names its parameters. Password shares from
before keep their PBKDF2-derived key.
- The upload page registers each selection with FileUploader into a
pending share of its own, lists the files with their progress, retries
a failed chunk after 1-16 s, then offers Retry; Remove and Cancel
abort. UploadChunkController only accepts chunks from the session that
started the share: a repeat is acknowledged, a skip gets 409 with the
count stored. Chunks go out as Blobs, which Chromium sends about eight
times faster than ArrayBuffers.
- Uploads need a secure context: over plain HTTP the page says HTTPS is
needed and takes no files. The Docker image gains AUTO_HTTPS, which
serves Let's Encrypt on 443 for SERVER_NAME and redirects 80; without
it the container stays on HTTP 80 behind a proxy. docker/Caddyfile was
never loaded and is gone; docker/healthcheck.sh covers both modes.
- "Download all" streams the ZIP with maennchen/zipstream-php (STORE,
ZIP64) instead of decrypting whole files into memory and writing the
archive unencrypted to /tmp.
- Pending shares count towards the quota, stay out of the admin
dashboard and 404 everywhere else. shares:cleanup deletes uploads idle
for 4 hours and Livewire temporary files older than that.
- PHP's upload limits no longer cap the admin's max file size and
default to 64M; LIVEWIRE_MAX_UPLOAD_TIME is gone and
UPLOAD_CHUNK_SIZE_MB is new.
- Tests cover the format, key wrapping, registration limits, the chunk
endpoint's answers, completing a share, the streamed ZIP, cleanup,
and in Chromium a real chunked upload and the HTTPS warning; the
selected-files overflow test runs again. README, website, CHANGELOG
and .ai/rules follow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
403 lines
16 KiB
CSS
403 lines
16 KiB
CSS
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
|
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/foundation.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/layout/grid.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/layout/pane.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/layout/row.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/layout/stack.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/layout/surface.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/account-menu.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/alert.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/badge.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/button.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/card.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/checkbox.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/divider.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/empty-state.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/file.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/form.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/group.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/icon.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/input.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/list-item.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/list.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/loading.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/menu-item.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/modal.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/pagination.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/password.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/progress.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/scheme-picker.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/section-nav.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/select.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/shape.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/sort-header.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/stat.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/table.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/textarea.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/theme-toggle.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/toast.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/toggle.css';
|
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/toolbar.css';
|
|
@import './material-scheme.css';
|
|
|
|
/*
|
|
* SealShare's own rules, unlayered so they outrank every package rule: one section per view, in
|
|
* the order a visitor meets them — the two layouts, the share flow (upload, share created,
|
|
* download), the settings pages in their navigation's order, then admin.
|
|
*/
|
|
|
|
/*
|
|
* resources/views/layouts/app.blade.php: the signed-in and public pages' main column.
|
|
*
|
|
* `<x-pane as="main">` gives the column its horizontal M3 margin (16px below `medium`, 24px from
|
|
* it) and centres it; its cap is SealShare's own 64rem, margins included, which no `width` preset
|
|
* (40, 60, 80rem) matches. The vertical rhythm is the app's own. The bottom padding clears the
|
|
* floating toolbar in partials/toolbar.blade.php by what the toolbar publishes as
|
|
* `--material-bottom-toolbar` (its top edge's distance from the window's bottom, safe area
|
|
* included), plus 16px. Never set `--material-bottom-bar` here: the toolbar reads it to place
|
|
* itself.
|
|
*/
|
|
.app-main {
|
|
max-inline-size: 64rem;
|
|
padding-block-start: var(--md-sys-measurement-space400);
|
|
padding-block-end: calc(var(--material-bottom-toolbar, 0px) + var(--md-sys-measurement-space200));
|
|
}
|
|
|
|
@media (width >= 600px) {
|
|
.app-main {
|
|
padding-block-start: var(--md-sys-measurement-space600);
|
|
}
|
|
}
|
|
|
|
/*
|
|
* resources/views/layouts/auth.blade.php: the centred sign-in card.
|
|
*
|
|
* The card sits in a full-height flex column that top-aligns it below `medium` and centres it
|
|
* from there. The bottom padding clears the floating toolbar exactly as .app-main's does (see
|
|
* above); the horizontal padding and top padding stay flat at every width, as the 1.x layout had.
|
|
*/
|
|
.auth-main {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: flex-start;
|
|
min-block-size: 100dvh;
|
|
padding-inline: var(--md-sys-measurement-space200);
|
|
padding-block-start: var(--md-sys-measurement-space400);
|
|
padding-block-end: calc(var(--material-bottom-toolbar, 0px) + var(--md-sys-measurement-space200));
|
|
}
|
|
|
|
@media (width >= 600px) {
|
|
.auth-main {
|
|
align-items: center;
|
|
}
|
|
}
|
|
|
|
.auth-card {
|
|
inline-size: 100%;
|
|
max-inline-size: 28rem;
|
|
}
|
|
|
|
@media (width >= 600px) {
|
|
.auth-card {
|
|
padding: var(--md-sys-measurement-space400);
|
|
}
|
|
}
|
|
|
|
/*
|
|
* resources/views/livewire/file-uploader.blade.php: the upload page, kept to 1.x's centred 48rem
|
|
* measure (max-w-3xl) inside the 64rem main column, which no `<x-pane>` width preset (40, 60, 80rem)
|
|
* matches. Its text fields stop at 40rem from `medium`; at 64rem the drop zone, the divider and the
|
|
* end-aligned submit ran some 21rem past the fields' edge, at 48rem 8rem.
|
|
*/
|
|
.upload-column {
|
|
max-inline-size: 48rem;
|
|
}
|
|
|
|
/* resources/views/livewire/file-uploader.blade.php: the site's own mark above the title, at 1.x's 5rem-tall size, its width following the image. */
|
|
.upload-site-logo {
|
|
block-size: 5rem;
|
|
}
|
|
|
|
/*
|
|
* resources/views/livewire/file-uploader.blade.php: the drop zone's dashed outline and its
|
|
* primary tint while dragging. `data-dragging` is Alpine's, not the package's, since no
|
|
* component tracks a native drag over an arbitrary drop target; disabled where uploads cannot run
|
|
* (no secure context) blocks pointer events and dims to M3's disabled-content opacity, as a code dims elsewhere while
|
|
* busy (.settings-recovery-code--loading).
|
|
*/
|
|
.upload-drop-zone {
|
|
padding: var(--md-sys-measurement-space400);
|
|
border: 2px dashed var(--md-sys-color-outline-variant);
|
|
border-radius: var(--md-sys-shape-corner-xl);
|
|
transition: border-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default), background-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
|
|
}
|
|
|
|
.upload-drop-zone[data-dragging='true'] {
|
|
border-color: var(--md-sys-color-primary);
|
|
background-color: color-mix(in srgb, var(--md-sys-color-primary-container) 40%, transparent);
|
|
}
|
|
|
|
.upload-drop-zone[aria-disabled='true'] {
|
|
pointer-events: none;
|
|
opacity: var(--md-sys-state-disabled-content-opacity);
|
|
}
|
|
|
|
/*
|
|
* resources/views/livewire/file-uploader.blade.php: the drop zone's shape morphs into a burst
|
|
* while files are dragged over it — SealShare's signature, kept from 1.x (docs/reference/m3/styles.md
|
|
* § Shape: "Shape morph should respond to user interaction"). Two `<x-shape>`s sit
|
|
* stacked (`inset: 0` on an absolutely positioned element sizes it to the box, no width/height
|
|
* class needed) and cross-fade/scale on the spatial-slow spring the shape's size warrants
|
|
* (docs/reference/m3/styles.md § Motion: "larger elements may use slow"); opacity rides the
|
|
* effects-slow spring beside it, since a colour or fade must never overshoot. Reduced motion needs
|
|
* no local override: the tokens themselves zero out under it (tokens/motion.css).
|
|
*/
|
|
.upload-drop-shapes {
|
|
position: relative;
|
|
display: grid;
|
|
place-items: center;
|
|
inline-size: 7rem;
|
|
block-size: 7rem;
|
|
}
|
|
|
|
.upload-drop-shape {
|
|
position: absolute;
|
|
inset: 0;
|
|
transition: scale var(--md-sys-motion-spatial-slow-duration) var(--md-sys-motion-spatial-slow), rotate var(--md-sys-motion-spatial-slow-duration) var(--md-sys-motion-spatial-slow), opacity var(--md-sys-motion-effects-slow-duration) var(--md-sys-motion-effects-slow);
|
|
}
|
|
|
|
.upload-drop-shape--idle {
|
|
scale: 1;
|
|
rotate: 0deg;
|
|
opacity: 1;
|
|
color: var(--md-sys-color-secondary-container);
|
|
}
|
|
|
|
.upload-drop-zone[data-dragging='true'] .upload-drop-shape--idle {
|
|
scale: 0.5;
|
|
rotate: 45deg;
|
|
opacity: 0;
|
|
}
|
|
|
|
.upload-drop-shape--burst {
|
|
scale: 0.5;
|
|
rotate: -45deg;
|
|
opacity: 0;
|
|
color: var(--md-sys-color-primary-container);
|
|
}
|
|
|
|
.upload-drop-zone[data-dragging='true'] .upload-drop-shape--burst {
|
|
scale: 1.1;
|
|
rotate: 0deg;
|
|
opacity: 1;
|
|
}
|
|
|
|
.upload-drop-icon {
|
|
position: relative;
|
|
color: var(--md-sys-color-on-secondary-container);
|
|
transition: color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
|
|
}
|
|
|
|
.upload-drop-zone[data-dragging='true'] .upload-drop-icon {
|
|
color: var(--md-sys-color-on-primary-container);
|
|
}
|
|
|
|
/* resources/views/livewire/file-uploader.blade.php: the selected-files list scrolls on its own past 1.x's cap instead of pushing the options and the submit button down the page. */
|
|
.upload-file-list {
|
|
max-block-size: 18rem;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
/*
|
|
* resources/views/livewire/share-created.blade.php: the page a share's link is ready on, kept to
|
|
* 1.x's centred 32rem measure, which no `<x-pane>` width preset (40, 60, 80rem) matches.
|
|
*/
|
|
.share-column {
|
|
max-inline-size: 32rem;
|
|
}
|
|
|
|
/*
|
|
* resources/views/livewire/share-created.blade.php: the check that settles onto its Expressive
|
|
* shape once the link is ready (the `share-ready`/`share-ready-fade` keyframes after it). The shape
|
|
* sits at the box's edges (`inset: 0` on an absolutely positioned element sizes it, no width/height
|
|
* class needed); both colours are container roles `md-ink-*` has no class for, so they are the
|
|
* application's own CSS rather than a component prop.
|
|
*/
|
|
.share-check {
|
|
position: relative;
|
|
display: grid;
|
|
place-items: center;
|
|
inline-size: 6rem;
|
|
block-size: 6rem;
|
|
animation:
|
|
share-ready var(--md-sys-motion-spatial-slow-duration) var(--md-sys-motion-spatial-slow) both,
|
|
share-ready-fade var(--md-sys-motion-effects-slow-duration) var(--md-sys-motion-effects-slow) both;
|
|
}
|
|
|
|
.share-check-shape {
|
|
position: absolute;
|
|
inset: 0;
|
|
color: var(--md-sys-color-primary-container);
|
|
}
|
|
|
|
.share-check-icon {
|
|
/* Without this the icon, though later in the DOM, is a non-positioned in-flow child: it paints
|
|
before the absolutely positioned shape beside it (CSS's stacking order for z-index:auto) and
|
|
sits hidden underneath it, as .upload-drop-icon's own position: relative is there to avoid. */
|
|
position: relative;
|
|
color: var(--md-sys-color-on-primary-container);
|
|
}
|
|
|
|
/*
|
|
* resources/views/livewire/share-created.blade.php: the check settling onto its shape, run by
|
|
* .share-check — rotate and scale on the spatial spring (shape motion), opacity on effects beside
|
|
* it, since M3 never lets a colour or fade overshoot; reduced motion needs no local override, the
|
|
* duration tokens themselves zero out under it.
|
|
*/
|
|
@keyframes share-ready {
|
|
from {
|
|
rotate: -90deg;
|
|
scale: 0.4;
|
|
}
|
|
|
|
to {
|
|
rotate: 0deg;
|
|
scale: 1;
|
|
}
|
|
}
|
|
|
|
@keyframes share-ready-fade {
|
|
from {
|
|
opacity: 0;
|
|
}
|
|
|
|
to {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
/*
|
|
* resources/views/livewire/share-created.blade.php: the QR code dialog. `App\Services\QrCodeService`
|
|
* already draws its SVG black on white with a four-module quiet zone, so the container adds no
|
|
* colour of its own — no colour class or literal colour could give it one that also holds in dark
|
|
* mode. The corner only rounds the container that clips it, exactly as .settings-two-factor-qr's does.
|
|
*/
|
|
.share-qr {
|
|
display: grid;
|
|
place-items: center;
|
|
inline-size: 100%;
|
|
max-inline-size: 20rem;
|
|
aspect-ratio: 1;
|
|
margin-inline: auto;
|
|
overflow: hidden;
|
|
border-radius: var(--md-sys-shape-corner-lg);
|
|
}
|
|
|
|
.share-qr svg {
|
|
inline-size: 100%;
|
|
block-size: 100%;
|
|
}
|
|
|
|
/*
|
|
* resources/views/livewire/share-download.blade.php: the page a recipient opens, kept to the same
|
|
* centred 32rem measure as share-created.blade.php.
|
|
*/
|
|
.download-column {
|
|
max-inline-size: 32rem;
|
|
}
|
|
|
|
/* resources/views/livewire/share-download.blade.php: the site's own mark above the title, at 1.x's 5rem-tall size. */
|
|
.download-site-logo {
|
|
block-size: 5rem;
|
|
}
|
|
|
|
/* resources/views/partials/settings-heading.blade.php: the settings pages' shared heading, spaced above their section navigation and content. */
|
|
.settings-heading {
|
|
margin-block-end: var(--md-sys-measurement-space300);
|
|
}
|
|
|
|
/*
|
|
* resources/views/pages/settings/layout.blade.php: the settings cards inside the 64rem main
|
|
* column. 1.x kept the form itself to 32rem (max-w-lg) under a full-width heading; the card now
|
|
* holds both, so the cap moves to the card and gains the card's own 16dp of side padding — 34rem,
|
|
* which leaves the fields at 1.x's measure. The section navigation above stays full width, as it
|
|
* was.
|
|
*/
|
|
.settings-column {
|
|
max-inline-size: 34rem;
|
|
}
|
|
|
|
/*
|
|
* resources/views/pages/settings/two-factor.blade.php: the setup QR code. Fortify's own
|
|
* twoFactorQrCodeSvg() draws no quiet zone, so the SVG comes from App\Services\QrCodeService
|
|
* against the same otpauth URL instead, which bakes in its own white field and four-module quiet
|
|
* zone — the only way to guarantee one in dark mode, since no colour class or literal colour can
|
|
* paint it onto 2.0.0's foundation. Sized at 1.x's 16rem square, corners rounded and clipped to
|
|
* match the settings surfaces around it.
|
|
*/
|
|
.settings-two-factor-qr {
|
|
display: grid;
|
|
place-items: center;
|
|
inline-size: 16rem;
|
|
aspect-ratio: 1;
|
|
overflow: hidden;
|
|
border-radius: var(--md-sys-shape-corner-lg);
|
|
}
|
|
|
|
.settings-two-factor-qr svg {
|
|
inline-size: 100%;
|
|
block-size: 100%;
|
|
}
|
|
|
|
/*
|
|
* resources/views/pages/settings/two-factor/recovery-codes.blade.php: a code dims to M3's disabled
|
|
* content opacity while regenerateRecoveryCodes() is in flight, and back, on the effects spring
|
|
* instead of Tailwind's animate-pulse loop — 2.0.0 keeps no keyframe utility for it. The
|
|
* transition sits on the code itself so the way back eases too; under reduced motion the token's
|
|
* duration is 0ms.
|
|
*/
|
|
.settings-recovery-code {
|
|
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
|
}
|
|
|
|
.settings-recovery-code--loading {
|
|
opacity: var(--md-sys-state-disabled-content-opacity);
|
|
}
|
|
|
|
/*
|
|
* resources/views/pages/settings/appearance.blade.php: the theme picker stays a comfortable
|
|
* width instead of stretching the full settings column. No `<x-group>` width prop caps it, and
|
|
* 24rem matches no `<x-pane>` preset.
|
|
*/
|
|
.settings-appearance-picker {
|
|
max-inline-size: 24rem;
|
|
}
|
|
|
|
/*
|
|
* resources/views/livewire/admin/admin-dashboard.blade.php: the shares table scrolls sideways on
|
|
* its own, on a window too narrow for every column, instead of the page around it.
|
|
*/
|
|
.admin-shares-table-scroll {
|
|
overflow-x: auto;
|
|
}
|
|
|
|
/*
|
|
* resources/views/livewire/admin/admin-settings.blade.php: the settings keep 1.x's own narrower
|
|
* measure inside the 64rem main column, 42rem, which no `<x-pane>` width preset (40, 60, 80rem)
|
|
* matches; the pane centres it.
|
|
*/
|
|
.admin-settings {
|
|
max-inline-size: 42rem;
|
|
}
|
|
|
|
/*
|
|
* resources/views/livewire/admin/admin-settings.blade.php: the current and previewed site logo,
|
|
* at 1.x's 4rem height with its width following the image's own ratio. M3 keeps no size scale for
|
|
* a plain <img>.
|
|
*/
|
|
.admin-settings-logo {
|
|
block-size: 4rem;
|
|
border-radius: var(--md-sys-shape-corner-sm);
|
|
}
|