Compare commits

..

No commits in common. "bca9d6b5e90d9ad8292a6eb694b5ed31d919099d" and "09055b98742809b32ed7aeaeef951313f674ce0d" have entirely different histories.

5 changed files with 11 additions and 122 deletions

View file

@ -22,9 +22,4 @@ class ApiTokenAuthMiddleware:
api_token = ApiToken.objects.select_related('user').filter(token=token).first() api_token = ApiToken.objects.select_related('user').filter(token=token).first()
if api_token is not None: if api_token is not None:
request.user = api_token.user request.user = api_token.user
# Browsers never attach this header on their own, so a
# token request can't be a cross-site forgery; native
# clients have no CSRF cookie to send. (CsrfViewMiddleware
# checks this flag in process_view, which runs after us.)
request._dont_enforce_csrf_checks = True
return self.get_response(request) return self.get_response(request)

View file

@ -42,20 +42,6 @@ class ApiTokenAuthMiddlewareTests(TestCase):
resp = self.client.get('/books/', HTTP_AUTHORIZATION=f'Bearer {self.token.token}') resp = self.client.get('/books/', HTTP_AUTHORIZATION=f'Bearer {self.token.token}')
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
def test_bearer_token_needs_no_csrf_token(self):
# import_m3u is not csrf_exempt: without a token it must still be protected.
from django.test import Client
client = Client(enforce_csrf_checks=True)
m3u = '#EXTM3U\n#EXTINF:-1,Test FM\nhttps://example.com/stream\n'
resp = client.post('/radio/import/', data=m3u, content_type='text/plain')
self.assertIn(resp.status_code, (302, 401, 403))
resp = client.post('/radio/import/', data=m3u, content_type='text/plain',
HTTP_AUTHORIZATION=f'Bearer {self.token.token}')
self.assertNotEqual(resp.status_code, 403)
resp = client.post('/radio/import/', data=m3u, content_type='text/plain',
HTTP_AUTHORIZATION='Bearer not-a-real-token')
self.assertIn(resp.status_code, (302, 401, 403))
def test_regenerate_view_requires_login_and_rotates_token(self): def test_regenerate_view_requires_login_and_rotates_token(self):
resp = self.client.post('/accounts/api-token/regenerate/') resp = self.client.post('/accounts/api-token/regenerate/')
self.assertEqual(resp.status_code, 302) self.assertEqual(resp.status_code, 302)

View file

@ -2130,7 +2130,7 @@ body.reader-immersive.reader-show-bottom .reader-overlay { bottom: var(--bar-h)
.footnote-popover-close { position:absolute; top:4px; right:6px; background:none; border:none; color:inherit; opacity:0.5; cursor:pointer; font-size:14px; padding:2px 4px; line-height:1; } .footnote-popover-close { position:absolute; top:4px; right:6px; background:none; border:none; color:inherit; opacity:0.5; cursor:pointer; font-size:14px; padding:2px 4px; line-height:1; }
/* Highlight marks */ /* Highlight marks */
.reader-no-bold * { font-weight: inherit !important; } .reader-no-bold * { font-weight: normal !important; }
.reader-content img { max-width: 100%; height: auto; display: block; } .reader-content img { max-width: 100%; height: auto; display: block; }
/* color:inherit overrides the browser's UA-default `mark { color: black }` — /* color:inherit overrides the browser's UA-default `mark { color: black }` —
without it, highlighted text abruptly flips to black regardless of the without it, highlighted text abruptly flips to black regardless of the

View file

@ -157,9 +157,7 @@ function customPrompt(message, defaultValue = '') {
function playStation(url, name, stationId) { function playStation(url, name, stationId) {
stopPlayback(false); stopPlayback(false);
// A secure page can't load plain-http streams (mixed content). Native shells if (location.protocol === 'https:' && url.startsWith('http://')) {
// that play the stream themselves set window.DIORA_PLAYS_HTTP_STREAMS.
if (location.protocol === 'https:' && url.startsWith('http://') && !window.DIORA_PLAYS_HTTP_STREAMS) {
window.open(url, '_blank'); window.open(url, '_blank');
return; return;
} }
@ -282,18 +280,15 @@ async function startPlaySession(stationName, stationUrl) {
} }
async function stopPlaySession() { async function stopPlaySession() {
// Take the id before awaiting: playStation() calls this without waiting and if (!currentPlayId) return;
// starts the next session right away, whose id must not be cleared here.
const playId = currentPlayId;
if (!playId) return;
currentPlayId = null;
try { try {
await fetch('/radio/play/stop/', { await fetch('/radio/play/stop/', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({play_id: playId}) body: JSON.stringify({play_id: currentPlayId})
}); });
} catch (e) {} } catch (e) {}
currentPlayId = null;
} }
window.addEventListener('beforeunload', () => { window.addEventListener('beforeunload', () => {
@ -3259,7 +3254,7 @@ async function _syncPendingAnnotations() {
// Reader settings // Reader settings
let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark', let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark',
fontFamily: 'serif', noBold: false, fontWeight: null, fontFamily: 'serif', noBold: false,
pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false }; pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false };
let readerSettingsPanelOpen = false; let readerSettingsPanelOpen = false;
let currentPdfDoc = null; let currentPdfDoc = null;
@ -4510,30 +4505,7 @@ function exitReaderImmersiveMode() {
document.removeEventListener('click', _immHandleTap); document.removeEventListener('click', _immHandleTap);
} }
// Serializes openBook: it empties #reader-content, then appends the chapters
// across several awaits. A second call in between (a second tap while a slow
// e-reader is still decrypting) appended the whole book a second time — and
// progress saved from that doubled DOM had block indices beyond the real book,
// which then out-ranked every real position on the server.
let _openBookInFlight = null;
let _repairProgressAnchor = false; // stored anchor didn't fit this book
async function openBook(bookId) { async function openBook(bookId) {
if (_openBookInFlight) {
if (_openBookInFlight.bookId === bookId) return _openBookInFlight.promise;
await _openBookInFlight.promise.catch(() => {});
}
const promise = _openBookOnce(bookId);
_openBookInFlight = {bookId, promise};
try {
return await promise;
} finally {
if (_openBookInFlight && _openBookInFlight.promise === promise) _openBookInFlight = null;
}
}
async function _openBookOnce(bookId) {
_repairProgressAnchor = false;
const overlay = $('reader-overlay'); const overlay = $('reader-overlay');
const contentEl = $('reader-content'); const contentEl = $('reader-content');
const titleEl = $('reader-title'); const titleEl = $('reader-title');
@ -4718,14 +4690,7 @@ async function _openBookOnce(bookId) {
} }
await new Promise(r => requestAnimationFrame(r)); await new Promise(r => requestAnimationFrame(r));
if (!restoreFromAnchor(contentEl, anchor) && fraction > 0) { if (!restoreFromAnchor(contentEl, anchor) && fraction > 0) {
restoreFromAnchor(contentEl, _anchorForFraction(contentEl, fraction)); contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight);
}
// An anchor past the end of this book can only come from a broken
// render. The server keeps the furthest block index, so it would block
// every real position from now on: overwrite it with the next save.
const [storedBlock] = _anchorParts(anchor);
if (storedBlock >= contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR).length) {
_repairProgressAnchor = true;
} }
} }
} catch (e) {} } catch (e) {}
@ -4879,13 +4844,6 @@ async function saveReaderProgress(force = false) {
fraction = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1); fraction = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1);
} }
// Chapter wrappers carry unique ids; seeing one twice means the book was
// rendered twice. Positions from that DOM are wrong — don't store them.
if (!currentPdfDoc && contentEl.querySelectorAll('#epub-chapter-0').length > 1) {
console.warn('Book rendered twice, progress not saved');
return;
}
let anchor = ''; let anchor = '';
if (!currentPdfDoc) { if (!currentPdfDoc) {
anchor = getPositionAnchor(contentEl); anchor = getPositionAnchor(contentEl);
@ -4896,11 +4854,9 @@ async function saveReaderProgress(force = false) {
} }
fraction = Math.min(1.0, Math.max(0.0, fraction)); fraction = Math.min(1.0, Math.max(0.0, fraction));
_updateCachedBookProgress(currentBookId, fraction, anchor, force || (_repairProgressAnchor && !!anchor)); _updateCachedBookProgress(currentBookId, fraction, anchor, force);
const repair = _repairProgressAnchor && !!anchor; const body = JSON.stringify({scroll_fraction: fraction, position_anchor: anchor, ...(force && {force: true})});
if (repair) _repairProgressAnchor = false;
const body = JSON.stringify({scroll_fraction: fraction, position_anchor: anchor, ...((force || repair) && {force: true})});
_lastProgressBeacon = {url: `/books/${currentBookId}/progress/`, body}; _lastProgressBeacon = {url: `/books/${currentBookId}/progress/`, body};
try { try {
@ -5070,7 +5026,7 @@ function jumpToTocEntry(href) {
function loadReaderSettings(bookId) { function loadReaderSettings(bookId) {
// Reset to defaults, then apply per-book overrides // Reset to defaults, then apply per-book overrides
Object.assign(readerSettings, { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark', Object.assign(readerSettings, { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark',
fontFamily: 'serif', noBold: false, fontWeight: null, fontFamily: 'serif', noBold: false,
pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false }); pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false });
try { try {
const saved = JSON.parse(localStorage.getItem(`diora_reader_settings_${bookId}`) || '{}'); const saved = JSON.parse(localStorage.getItem(`diora_reader_settings_${bookId}`) || '{}');
@ -5104,16 +5060,6 @@ function applyReaderSettings(isPdf) {
}; };
contentEl.style.fontFamily = fontMap[readerSettings.fontFamily] || fontMap.serif; contentEl.style.fontFamily = fontMap[readerSettings.fontFamily] || fontMap.serif;
contentEl.classList.toggle('reader-no-bold', !!readerSettings.noBold); contentEl.classList.toggle('reader-no-bold', !!readerSettings.noBold);
// Weight: null keeps the stylesheet's. --reader-weight lets old WebViews
// without variable-font support (which drop weights like 425) pick a
// static cut instead.
contentEl.style.fontWeight = '';
if (readerSettings.fontWeight) {
contentEl.style.fontWeight = String(readerSettings.fontWeight);
contentEl.style.setProperty('--reader-weight', String(readerSettings.fontWeight));
} else {
contentEl.style.removeProperty('--reader-weight');
}
if (_currentPositionAnchor && currentBookId) { if (_currentPositionAnchor && currentBookId) {
_suppressScrollJumpDetect(); _suppressScrollJumpDetect();
requestAnimationFrame(() => restoreFromAnchor($('reader-content'), _currentPositionAnchor)); requestAnimationFrame(() => restoreFromAnchor($('reader-content'), _currentPositionAnchor));
@ -5137,19 +5083,6 @@ function applyReaderSettings(isPdf) {
if (isPdf && markerModeActive) toggleMarkerMode(); if (isPdf && markerModeActive) toggleMarkerMode();
} }
// Font weight slider: the variable Source Serif 4 takes any weight, the
// system fonts only their fixed cuts (multiples of 100).
function _readerWeightStep() {
return readerSettings.fontFamily === 'serif' ? 25 : 100;
}
function _readerWeight() {
const step = _readerWeightStep();
const w = readerSettings.fontWeight
|| parseInt(getComputedStyle($('reader-content')).fontWeight, 10) || 400;
return Math.round(w / step) * step;
}
function toggleSettingsPanel() { function toggleSettingsPanel() {
const overlay = $('reader-overlay'); const overlay = $('reader-overlay');
const contentEl = $('reader-content'); const contentEl = $('reader-content');
@ -5175,7 +5108,6 @@ function toggleSettingsPanel() {
<label>Line <button class="btn btn-sm" id="rs-line-minus">−</button> <input type="range" id="rs-line" min="12" max="30" step="1" value="${Math.round(readerSettings.lineHeight * 10)}"> <button class="btn btn-sm" id="rs-line-plus">+</button> <span id="rs-line-val">${readerSettings.lineHeight}</span></label> <label>Line <button class="btn btn-sm" id="rs-line-minus">−</button> <input type="range" id="rs-line" min="12" max="30" step="1" value="${Math.round(readerSettings.lineHeight * 10)}"> <button class="btn btn-sm" id="rs-line-plus">+</button> <span id="rs-line-val">${readerSettings.lineHeight}</span></label>
<label>Width <button class="btn btn-sm" id="rs-width-minus">−</button> <input type="range" id="rs-width" min="40" max="90" step="5" value="${readerSettings.maxWidth}"> <button class="btn btn-sm" id="rs-width-plus">+</button> <span id="rs-width-val">${readerSettings.maxWidth}ch</span></label> <label>Width <button class="btn btn-sm" id="rs-width-minus">−</button> <input type="range" id="rs-width" min="40" max="90" step="5" value="${readerSettings.maxWidth}"> <button class="btn btn-sm" id="rs-width-plus">+</button> <span id="rs-width-val">${readerSettings.maxWidth}ch</span></label>
<button class="btn btn-sm" id="rs-width-full">Full</button> <button class="btn btn-sm" id="rs-width-full">Full</button>
<label>Weight <button class="btn btn-sm" id="rs-weight-minus">−</button> <input type="range" id="rs-weight" min="200" max="800" step="${_readerWeightStep()}" value="${_readerWeight()}"> <button class="btn btn-sm" id="rs-weight-plus">+</button> <span id="rs-weight-val">${_readerWeight()}</span></label>
<button class="btn btn-sm ${readerSettings.fontFamily === 'serif' ? 'active' : ''}" data-rs-font="serif">Serif</button> <button class="btn btn-sm ${readerSettings.fontFamily === 'serif' ? 'active' : ''}" data-rs-font="serif">Serif</button>
<button class="btn btn-sm ${readerSettings.fontFamily === 'sans' ? 'active' : ''}" data-rs-font="sans">Sans</button> <button class="btn btn-sm ${readerSettings.fontFamily === 'sans' ? 'active' : ''}" data-rs-font="sans">Sans</button>
<button class="btn btn-sm ${readerSettings.fontFamily === 'humanist' ? 'active' : ''}" data-rs-font="humanist">Verdana</button> <button class="btn btn-sm ${readerSettings.fontFamily === 'humanist' ? 'active' : ''}" data-rs-font="humanist">Verdana</button>
@ -5255,36 +5187,12 @@ function toggleSettingsPanel() {
saveReaderSettings(); saveReaderSettings();
}); });
const weightRange = panel.querySelector('#rs-weight');
const weightVal = panel.querySelector('#rs-weight-val');
weightRange.addEventListener('input', () => {
readerSettings.fontWeight = parseInt(weightRange.value, 10);
weightVal.textContent = readerSettings.fontWeight;
applyReaderSettings(false);
saveReaderSettings();
});
panel.querySelector('#rs-weight-minus').addEventListener('click', () => {
weightRange.value = Math.max(+weightRange.min, parseInt(weightRange.value, 10) - +weightRange.step);
weightRange.dispatchEvent(new Event('input'));
});
panel.querySelector('#rs-weight-plus').addEventListener('click', () => {
weightRange.value = Math.min(+weightRange.max, parseInt(weightRange.value, 10) + +weightRange.step);
weightRange.dispatchEvent(new Event('input'));
});
panel.querySelectorAll('[data-rs-font]').forEach(btn => { panel.querySelectorAll('[data-rs-font]').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
readerSettings.fontFamily = btn.dataset.rsFont; readerSettings.fontFamily = btn.dataset.rsFont;
panel.querySelectorAll('[data-rs-font]').forEach(b => b.classList.toggle('active', b === btn)); panel.querySelectorAll('[data-rs-font]').forEach(b => b.classList.toggle('active', b === btn));
// The system fonts only have cuts at multiples of 100.
weightRange.step = _readerWeightStep();
if (readerSettings.fontWeight) {
readerSettings.fontWeight = Math.round(readerSettings.fontWeight / weightRange.step) * weightRange.step;
}
applyReaderSettings(false); applyReaderSettings(false);
saveReaderSettings(); saveReaderSettings();
weightRange.value = _readerWeight();
weightVal.textContent = _readerWeight();
}); });
}); });

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use. * diora service worker — caches the app shell for offline use.
*/ */
const CACHE = 'diora-v49'; const CACHE = 'diora-v46';
const PODCAST_CACHE = 'diora-podcast-v1'; const PODCAST_CACHE = 'diora-podcast-v1';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',