Optimize encryption, storage, and media streaming performance (#447)

- AES-256-GCM in-place decryption halves peak RAM in encrypted blob backend
- Offload crypto ≥64 KiB to spawn_blocking (unblocks async runtime)
- Fix off-by-one in encrypted range stream (end now exclusive)
- Collapse 3 DB round-trips for quota updates into 1 correlated UPDATE
- Set-based reconciliation sweep replaces per-user task spawning
- Eliminate entity re-read after file overwrite via RETURNING clause
- Lightbox streams video/photos inline instead of fetch→blob
This commit is contained in:
Dionisio Pozo
2026-06-10 14:53:55 +02:00
committed by GitHub
parent 26b396490c
commit fe0053bc79
10 changed files with 440 additions and 227 deletions
+100 -38
View File
@@ -1,6 +1,13 @@
/**
* OxiCloud - Photos Lightbox
* Full-screen image/video viewer with prev/next navigation.
*
* Media is never buffered in page memory: videos stream straight from the
* API (the element's `src` is same-origin, so auth cookies travel
* automatically and the browser issues Range requests — playback starts
* progressively and seeking works without downloading the whole file).
* Photos open with the server-cached `large` thumbnail; the full-resolution
* original streams in only on demand via the toolbar expand button.
*/
import { getCsrfHeaders } from '../../core/csrf.js';
@@ -16,12 +23,17 @@ export const photosLightbox = {
index: -1,
/** @type {HTMLElement|null} */
_overlay: null,
/** @type {string|null} Current blob URL to revoke */
_blobUrl: null,
/** @type {(ev: KeyboardEvent) => any|null} */
_keyHandler: null,
/** @type {PhotosView|null} Reference to photosView, set after both modules load */
_photosView: null,
/**
* Monotonic token identifying the most recent {@link photosLightbox._show}
* call. Image load/error callbacks fire asynchronously, so a rapid
* prev/next must not let a superseded item commit its (stale) content
* over the newer one.
*/
_showGeneration: 0,
/**
* Register the photosView reference (called from photos.js to avoid circular imports).
@@ -36,6 +48,25 @@ export const photosLightbox = {
return getCsrfHeaders();
},
/**
* Streaming URL of the original file. Same-origin, so media elements
* send the auth cookie automatically and the browser handles Range.
* @param {FileItem} item
* @returns {string}
*/
_originalUrl(item) {
return `/api/files/${item.id}?inline=true`;
},
/**
* URL of the server-cached `large` thumbnail (immutable, browser-cached).
* @param {FileItem} item
* @returns {string}
*/
_thumbUrl(item) {
return `/api/files/${item.id}/thumbnail/large`;
},
/**
* Open lightbox at given index
* @param {FileItem[]} items
@@ -60,7 +91,6 @@ export const photosLightbox = {
}
}, 200);
}
this._revokeBlob();
this._unbindKeys();
},
@@ -96,6 +126,7 @@ export const photosLightbox = {
<div class="lightbox-content"></div>
<button class="lightbox-nav lightbox-next"><i class="fas fa-chevron-right"></i></button>
<div class="lightbox-toolbar">
<button class="lb-fullres hidden" title="Full resolution"><i class="fas fa-expand"></i></button>
<button class="lb-download" title="Download"><i class="fas fa-download"></i></button>
<button class="lb-favorite" title="Favorite"><i class="far fa-star"></i></button>
<button class="lb-delete" title="Delete"><i class="fas fa-trash"></i></button>
@@ -117,7 +148,7 @@ export const photosLightbox = {
}
});
// Toolbar actions
// Toolbar actions (`.lb-fullres` is wired per-item in `_show`)
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-download')).onclick = () => this._download();
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-favorite')).onclick = () => this._toggleFavorite();
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-delete')).onclick = () => this._delete();
@@ -127,8 +158,9 @@ export const photosLightbox = {
},
/** Display the current item */
async _show() {
_show() {
if (!this._overlay || this.index < 0) return;
const generation = ++this._showGeneration;
const item = this.items[this.index];
const content = this._overlay.querySelector('.lightbox-content');
@@ -154,31 +186,75 @@ export const photosLightbox = {
/** @type {HTMLButtonElement} */ (this._overlay.querySelector('.lightbox-prev')).classList.toggle('hidden', !(this.index > 0));
/** @type {HTMLButtonElement} */ (this._overlay.querySelector('.lightbox-next')).classList.toggle('hidden', !(this.index < this.items.length - 1));
// Reset the full-resolution button for the new item
const fullResBtn = /** @type {HTMLButtonElement} */ (this._overlay.querySelector('.lb-fullres'));
fullResBtn.classList.add('hidden');
fullResBtn.disabled = false;
const fullResIcon = fullResBtn.querySelector('i');
if (fullResIcon) fullResIcon.className = 'fas fa-expand';
// Load content
this._revokeBlob();
content.innerHTML = '<div class="photos-loading"><i class="fas fa-spinner"></i></div>';
try {
const isVideo = item.mime_type?.startsWith('video/');
const res = await fetch(`/api/files/${item.id}`, {
credentials: 'include',
headers: this._headers()
if (item.mime_type?.startsWith('video/')) {
const video = document.createElement('video');
video.controls = true;
video.autoplay = true;
// Instant first frame while metadata loads; a 204 (no cached
// thumbnail yet) simply leaves the poster blank.
video.poster = this._thumbUrl(item);
video.src = this._originalUrl(item);
video.addEventListener('error', () => {
if (generation !== this._showGeneration) return;
content.innerHTML = '<div class="photos-loading">Failed to load</div>';
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
this._blobUrl = URL.createObjectURL(blob);
if (isVideo) {
content.innerHTML = `<video src="${this._blobUrl}" controls autoplay></video>`;
} else {
content.innerHTML = `<img src="${this._blobUrl}" alt="${this._escAttr(item.name)}">`;
}
} catch (err) {
console.error('Lightbox load error:', err);
content.innerHTML = '<div class="photos-loading">Failed to load</div>';
// The native player has its own buffering UI — drop the spinner now.
content.replaceChildren(video);
this._loadMetadata(item.id, meta, dateStr, item.size_formatted || '');
return;
}
// Photo: thumbnail first, original on demand. GIFs go straight to
// the original — the thumbnail is a static JPEG and would lose the
// animation.
const isGif = item.mime_type === 'image/gif';
let showingOriginal = isGif;
const img = document.createElement('img');
img.alt = item.name;
img.addEventListener('load', () => {
if (generation !== this._showGeneration) return;
// First load replaces the spinner; the on-demand swap reuses the
// already-attached element.
if (!img.isConnected) content.replaceChildren(img);
fullResBtn.classList.toggle('hidden', showingOriginal);
fullResBtn.disabled = false;
if (fullResIcon) fullResIcon.className = 'fas fa-expand';
});
img.addEventListener('error', () => {
if (generation !== this._showGeneration) return;
if (!showingOriginal) {
// No server thumbnail (unsupported format or generation
// failed) — fall back to the original.
showingOriginal = true;
img.src = this._originalUrl(item);
} else {
content.innerHTML = '<div class="photos-loading">Failed to load</div>';
fullResBtn.classList.add('hidden');
}
});
fullResBtn.onclick = () => {
if (generation !== this._showGeneration || showingOriginal) return;
showingOriginal = true;
fullResBtn.disabled = true;
if (fullResIcon) fullResIcon.className = 'fas fa-spinner fa-spin';
img.src = this._originalUrl(item);
};
img.src = showingOriginal ? this._originalUrl(item) : this._thumbUrl(item);
// Load EXIF metadata
this._loadMetadata(item.id, meta, dateStr, item.size_formatted || '');
},
@@ -295,19 +371,5 @@ export const photosLightbox = {
document.removeEventListener('keydown', this._keyHandler);
this._keyHandler = null;
}
},
_revokeBlob() {
if (this._blobUrl) {
URL.revokeObjectURL(this._blobUrl);
this._blobUrl = null;
}
},
/** @param {any} s */
_escAttr(s) {
return String(s || '')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;');
}
};