Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/guide/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ Get LibrisLog running in minutes.
- [Docker](https://docs.docker.com/get-docker/) (includes Docker Compose)
- `curl` or `wget` (to download files)

> **Camera features need a secure context**: The ISBN barcode scanner (and any camera use) only works when the app is served over **HTTPS** or via `http://localhost`. If you access the app over plain `http://` on a remote address, the camera won't start. See the [library guide](/guide/using-librislog/library#isbn-barcode-scan) for details.

## Setup

Download the files, create your environment, and generate a secure encryption key.
Expand Down
10 changes: 9 additions & 1 deletion docs/guide/using-librislog/library.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,15 @@ The search automatically tries Open Library first, then falls back to other sour

### ISBN Barcode Scan

On mobile devices, use the camera to scan ISBN barcodes. The app uses the device's camera with real-time barcode detection to quickly look up books.
Use the camera to scan ISBN barcodes. The app uses the device's camera with real-time barcode detection to quickly look up books.

::: warning Requires a secure context

Camera access is only available when LibrisLog is served in a **secure context**. A page is a secure context when it is served over **HTTPS** or from `http://localhost` (or `http://127.0.0.1`). Accessing the app via a plain `http://` address on a remote host — e.g. `http://192.168.1.10:8001` — is **not** a secure context, and the camera will not start. See [MDN: Secure contexts](https://developer.mozilla.org/en-US/docs/Web/Security/Dangerous_Contexts) for details.

If the barcode scan button is hidden or the scanner shows a black box, your browser is likely blocking camera access because the app is not running in a secure context. Serve LibrisLog behind HTTPS (a reverse proxy with a TLS certificate) or access it via `localhost` to enable scanning.

:::

## Editing Books

Expand Down
22 changes: 20 additions & 2 deletions frontend/src/lib/components/BarcodeScanner.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { _ } from '$lib/i18n';
import { onDestroy } from 'svelte';
import { RefreshCw, X } from '@lucide/svelte';
import { isSecureContext, SECURE_CONTEXT_DOCS_URL } from '$lib/utils/secureContext';

let {
open = $bindable(false),
Expand All @@ -16,6 +17,7 @@

let stream = $state<MediaStream | null>(null);
let scannerError = $state<string | null>(null);
let notSecure = $state(false);
let starting = $state(false);
let detectionLocked = $state(false);
let videoEl = $state<HTMLVideoElement | null>(null);
Expand Down Expand Up @@ -268,6 +270,10 @@

async function startScanner() {
if (starting || stream) return;
if (!isSecureContext()) {
notSecure = true;
return;
}
if (!navigator.mediaDevices?.getUserMedia) throw new Error($_('scanner.noCamera'));
starting = true;
scannerError = null;
Expand Down Expand Up @@ -320,12 +326,13 @@
}

$effect(() => {
if (open && !stream && !starting && !scannerError) {
if (open && !stream && !starting && !scannerError && !notSecure) {
void startScanner();
return;
}
if (!open) {
scannerError = null;
notSecure = false;
if (stream) {
void stopScanner();
}
Expand Down Expand Up @@ -366,7 +373,18 @@
</div>
{/if}

{#if !scannerError}
{#if notSecure}
<div class="alert alert-warning text-sm">
<span>
{$_('scanner.secureContextRequired')}{' '}
<a href={SECURE_CONTEXT_DOCS_URL} target="_blank" rel="noreferrer" class="link link-primary">
{$_('scanner.secureContextDocsLink')}
</a>
</span>
</div>
{/if}

{#if !scannerError && !notSecure}
<div class="flex-1 min-h-72 rounded-lg bg-black overflow-hidden relative">
<video
bind:this={videoEl}
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/lib/components/BarcodeScanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ function installMediaElementMocks() {
}
}

function setSecureContext(value: boolean) {
Object.defineProperty(window, 'isSecureContext', { configurable: true, value });
}

const ORIGINAL_SECURE_CONTEXT_DESCRIPTOR = Object.getOwnPropertyDescriptor(window, 'isSecureContext');

describe('BarcodeScanner', () => {
const CAMERAS: Camera[] = [
{ deviceId: 'cam-front', label: 'Front Camera' },
Expand All @@ -133,11 +139,15 @@ describe('BarcodeScanner', () => {
vi.clearAllMocks();
window.localStorage.clear();
installMediaElementMocks();
setSecureContext(true);
});

afterEach(() => {
cleanup();
vi.restoreAllMocks();
if (ORIGINAL_SECURE_CONTEXT_DESCRIPTOR) {
Object.defineProperty(window, 'isSecureContext', ORIGINAL_SECURE_CONTEXT_DESCRIPTOR);
}
});

it('requests the persisted camera with an exact deviceId', async () => {
Expand Down Expand Up @@ -335,4 +345,21 @@ describe('BarcodeScanner', () => {
});
expect(screen.queryByRole('button', { name: /switch camera/i })).not.toBeInTheDocument();
});

it('shows a secure-context warning and does not start the camera outside a secure context', async () => {
setSecureContext(false);
const { getUserMedia } = mockMediaDevices(CAMERAS);

render(BarcodeScanner, { props: { open: true } });

expect(await screen.findByText(/secure context/i)).toBeInTheDocument();
const link = screen.getByRole('link', { name: /learn more/i });
expect(link).toHaveAttribute(
'href',
'https://docs.librislog.app/guide/using-librislog/library.html#isbn-barcode-scan'
);
expect(getUserMedia).not.toHaveBeenCalled();
expect(screen.queryByRole('slider', { name: /zoom/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /switch camera/i })).not.toBeInTheDocument();
});
});
12 changes: 11 additions & 1 deletion frontend/src/lib/components/ImportSearch.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { toasts } from '$lib/toasts';
import { ScanBarcode } from '@lucide/svelte';
import { formatAuthors } from '$lib/utils/authors';
import { isSecureContext, SECURE_CONTEXT_DOCS_URL } from '$lib/utils/secureContext';

let {
onImport,
Expand All @@ -29,16 +30,18 @@
let supplementAddedCount = $state<number | null>(null);
let importing = $state<string | null>(null);
let cameraSupported = $state(false);
let secureContext = $state(false);
let lastHandledScannedIsbn = $state<string | null>(null);
let importedIsbns = $state<Set<string>>(new Set());
let importedTitleAuthors = $state<Set<string>>(new Set());
let acquisitionStatus = $state<AcquisitionStatus | ''>('');
let hasOlResults = $derived(results.some((r) => r.source === 'open_library'));

onMount(async () => {
secureContext = isSecureContext();
cameraSupported =
typeof navigator !== 'undefined' &&
window.isSecureContext &&
secureContext &&
!!navigator.mediaDevices &&
typeof navigator.mediaDevices.getUserMedia === 'function';
await refreshImportedLookups();
Expand Down Expand Up @@ -287,6 +290,13 @@
<span>{$_('import.scan')}</span>
</button>
</div>
{:else if !secureContext}
<p class="text-sm text-base-content/60">
{$_('import.scanUnavailable')}{' '}
<a href={SECURE_CONTEXT_DOCS_URL} target="_blank" rel="noreferrer" class="link link-primary">
{$_('import.scanUnavailableDocsLink')}
</a>
</p>
{/if}

{#if stages.length > 0}
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/lib/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,8 @@
"googleAdded": "Google-Books-Ergebnisse hinzugefügt: {count}",
"scan": "Scannen",
"scanIsbn": "ISBN-Barcode scannen",
"scanUnavailable": "Barcode-Scannen ist nicht verfügbar, weil die App nicht in einem sicheren Kontext bereitgestellt wird.",
"scanUnavailableDocsLink": "Mehr erfahren",
"importFailed": "Import fehlgeschlagen",
"searchFailed": "Suche fehlgeschlagen",
"scannedIsbn": "ISBN gescannt: {isbn}",
Expand All @@ -240,7 +242,9 @@
"switchCamera": "Kamera wechseln",
"zoom": "Zoom",
"zoomLevel": "Zoom {zoom}x",
"close": "Scanner schließen"
"close": "Scanner schließen",
"secureContextRequired": "Kamerazugriff erfordert einen sicheren Kontext (HTTPS oder localhost).",
"secureContextDocsLink": "Mehr erfahren"
},
"coverPicker": {
"dropzone": "Bild hierher ziehen oder",
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,8 @@
"googleAdded": "Google Books results added: {count}",
"scan": "Scan",
"scanIsbn": "Scan ISBN barcode",
"scanUnavailable": "Barcode scanning is unavailable because the app is not served in a secure context.",
"scanUnavailableDocsLink": "Learn more",
"importFailed": "Import failed",
"searchFailed": "Search failed",
"scannedIsbn": "Scanned ISBN: {isbn}",
Expand All @@ -240,7 +242,9 @@
"switchCamera": "Switch camera",
"zoom": "Zoom",
"zoomLevel": "Zoom {zoom}x",
"close": "Close scanner"
"close": "Close scanner",
"secureContextRequired": "Camera access requires a secure context (HTTPS or localhost).",
"secureContextDocsLink": "Learn more"
},
"coverPicker": {
"dropzone": "Drag & drop an image, or",
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/lib/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,8 @@
"googleAdded": "Resultados de Google Books añadidos: {count}",
"scan": "Escanear",
"scanIsbn": "Escanear código de barras ISBN",
"scanUnavailable": "El escaneo de códigos de barras no está disponible porque la aplicación no se sirve en un contexto seguro.",
"scanUnavailableDocsLink": "Más información",
"importFailed": "Importación fallida",
"searchFailed": "Búsqueda fallida",
"scannedIsbn": "ISBN escaneado: {isbn}",
Expand All @@ -240,7 +242,9 @@
"switchCamera": "Cambiar cámara",
"zoom": "Zoom",
"zoomLevel": "Zoom {zoom}x",
"close": "Cerrar escáner"
"close": "Cerrar escáner",
"secureContextRequired": "El acceso a la cámara requiere un contexto seguro (HTTPS o localhost).",
"secureContextDocsLink": "Más información"
},
"coverPicker": {
"dropzone": "Arrastra una imagen aquí, o",
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/lib/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,8 @@
"googleAdded": "Résultats Google Books ajoutés : {count}",
"scan": "Scanner",
"scanIsbn": "Scanner le code-barres ISBN",
"scanUnavailable": "Le scan de code-barres est indisponible car l'application n'est pas servie dans un contexte sécurisé.",
"scanUnavailableDocsLink": "En savoir plus",
"importFailed": "Échec de l'importation",
"searchFailed": "Échec de la recherche",
"scannedIsbn": "ISBN scanné : {isbn}",
Expand All @@ -240,7 +242,9 @@
"switchCamera": "Changer de caméra",
"zoom": "Zoom",
"zoomLevel": "Zoom {zoom}x",
"close": "Fermer le scanner"
"close": "Fermer le scanner",
"secureContextRequired": "L'accès à la caméra nécessite un contexte sécurisé (HTTPS ou localhost).",
"secureContextDocsLink": "En savoir plus"
},
"coverPicker": {
"dropzone": "Glisse et dépose une image ici, ou",
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/lib/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,8 @@
"googleAdded": "Google Books 结果已添加:{count}",
"scan": "扫描",
"scanIsbn": "扫描 ISBN 条码",
"scanUnavailable": "由于应用未在安全上下文中提供,条码扫描不可用。",
"scanUnavailableDocsLink": "了解更多",
"importFailed": "导入失败",
"searchFailed": "搜索失败",
"scannedIsbn": "已扫描 ISBN:{isbn}",
Expand All @@ -240,7 +242,9 @@
"switchCamera": "切换摄像头",
"zoom": "缩放",
"zoomLevel": "缩放 {zoom}x",
"close": "关闭扫描器"
"close": "关闭扫描器",
"secureContextRequired": "摄像头访问需要安全上下文(HTTPS 或 localhost)。",
"secureContextDocsLink": "了解更多"
},
"coverPicker": {
"dropzone": "拖放图片到此处,或",
Expand Down
30 changes: 30 additions & 0 deletions frontend/src/lib/utils/secureContext.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, it, expect, afterEach } from 'vitest';
import { isSecureContext, SECURE_CONTEXT_DOCS_URL } from '$lib/utils/secureContext';

describe('isSecureContext', () => {
const originalDescriptor = Object.getOwnPropertyDescriptor(window, 'isSecureContext');

afterEach(() => {
if (originalDescriptor) {
Object.defineProperty(window, 'isSecureContext', originalDescriptor);
}
});

it('returns true when window.isSecureContext is true', () => {
Object.defineProperty(window, 'isSecureContext', { configurable: true, value: true });
expect(isSecureContext()).toBe(true);
});

it('returns false when window.isSecureContext is false', () => {
Object.defineProperty(window, 'isSecureContext', { configurable: true, value: false });
expect(isSecureContext()).toBe(false);
});
});

describe('SECURE_CONTEXT_DOCS_URL', () => {
it('points to the library guide section on the docs site', () => {
expect(SECURE_CONTEXT_DOCS_URL).toBe(
'https://docs.librislog.app/guide/using-librislog/library.html#isbn-barcode-scan'
);
});
});
6 changes: 6 additions & 0 deletions frontend/src/lib/utils/secureContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const SECURE_CONTEXT_DOCS_URL =
'https://docs.librislog.app/guide/using-librislog/library.html#isbn-barcode-scan';

export function isSecureContext(): boolean {
return typeof window !== 'undefined' && window.isSecureContext === true;
}