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:
@@ -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, '"')
|
||||
.replace(/</g, '<');
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user