Modo libro 3D, tomos con cubiertas reales y ocultar créditos
Libro 3D (app/public/book3d.js): lector WebGL2 con mesa de madera, cubierta, hojas que giran, cámara real y giroscopio, ahora con más recorrido alrededor del libro. Tomos (app/volumes.js): agrupa los capítulos en un solo libro con portada y contraportada reales. El reparto sale de Wikipedia, MangaDex, M440 y MangaUpdates, contrastados entre sí. Lo que ninguna fuente sitúa se estima, de modo que solo quedan sueltos los capítulos del final, y los fuera de orden se recolocan. Manhwa y manhua no tienen tomos. Ocultar créditos (app/credits.js): botón por obra en la ficha. Al leer por tomos oculta, sin borrarlas, la página cero y las dos últimas de cada capítulo si se repiten en otros capítulos de la obra. HANDOFF-LIBRO3D.md recoge el estado, las decisiones y las pruebas. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+57
-2
@@ -15,6 +15,8 @@ import { setupSecurity, safeFetch } from './security.js'
|
||||
import { setupM440, MI_BASE } from './m440.js'
|
||||
import { setupConnectivity } from './connectivity.js'
|
||||
import { setupMeta } from './meta.js'
|
||||
import { setupVolumes } from './volumes.js'
|
||||
import { setupCredits } from './credits.js'
|
||||
import { setupBooks, isBookId } from './books.js'
|
||||
|
||||
const PORT = Number(process.env.PORT || 3352)
|
||||
@@ -771,6 +773,45 @@ function json(res, status, body, headers = {}) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers })
|
||||
res.end(JSON.stringify(body))
|
||||
}
|
||||
// Fondo de la mesa del libro 3D, por usuario. El navegador lo reduce antes de subirlo; aquí solo se aceptan estos formatos, mirando el contenido.
|
||||
const TABLE_DIR = path.join(DATA_DIR, 'tables'), TABLE_MAX = 24 * 1024 * 1024
|
||||
const tableType = b => {
|
||||
if (b.length < 16) return null
|
||||
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
|
||||
if (b.readUInt32BE(0) === 0x89504e47) return 'image/png'
|
||||
if (b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') return 'image/webp'
|
||||
return null
|
||||
}
|
||||
fs.mkdirSync(TABLE_DIR, { recursive: true })
|
||||
const tableFile = id => path.join(TABLE_DIR, `${Number(id)}.img`)
|
||||
const tableVersion = id => { try { return Math.round(fs.statSync(tableFile(id)).mtimeMs) } catch { return 0 } }
|
||||
async function tableRoute(method, req, res, user, q) {
|
||||
const file = tableFile(user.id)
|
||||
if (method === 'GET') {
|
||||
let buf
|
||||
try { buf = fs.readFileSync(file) } catch { throw new HttpError(404, 'Sin fondo') }
|
||||
res.writeHead(200, { 'Content-Type': tableType(buf) || 'application/octet-stream', 'Content-Length': buf.length, 'Cache-Control': q.get('v') ? 'private, max-age=31536000, immutable' : 'private, no-cache' })
|
||||
return res.end(buf)
|
||||
}
|
||||
if (method === 'DELETE') {
|
||||
fs.rmSync(file, { force: true })
|
||||
return json(res, 200, { tableV: 0 })
|
||||
}
|
||||
if (method !== 'POST') throw new HttpError(405, 'Método no permitido')
|
||||
let size = 0
|
||||
const chunks = []
|
||||
for await (const c of req) {
|
||||
size += c.length
|
||||
if (size > TABLE_MAX) throw new HttpError(413, 'La imagen es demasiado grande')
|
||||
chunks.push(c)
|
||||
}
|
||||
const buf = Buffer.concat(chunks)
|
||||
if (!tableType(buf)) throw new HttpError(400, 'La imagen debe ser JPEG, PNG o WebP')
|
||||
const tmp = `${file}.tmp`
|
||||
fs.writeFileSync(tmp, buf)
|
||||
fs.renameSync(tmp, file)
|
||||
return json(res, 200, { tableV: tableVersion(user.id) })
|
||||
}
|
||||
async function readBody(req) {
|
||||
let size = 0
|
||||
const chunks = []
|
||||
@@ -801,6 +842,7 @@ const assetVersion = name => crypto.createHash('sha256').update(fs.readFileSync(
|
||||
const INDEX_HTML = fs.readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8')
|
||||
.replace('src="/app.js"', `src="/app.js?v=${assetVersion('app.js')}"`)
|
||||
.replace('href="/style.css"', `href="/style.css?v=${assetVersion('style.css')}"`)
|
||||
.replace('<meta name="b3d-version" content="">', `<meta name="b3d-version" content="${assetVersion('book3d.js')}">`)
|
||||
.replace('<meta name="sw-version" content="">', `<meta name="sw-version" content="${assetVersion('sw.js')}">`)
|
||||
function serveStatic(res, pathname) {
|
||||
let file = path.join(PUBLIC_DIR, path.normalize(pathname).replace(/^(\.\.[/\\])+/, ''))
|
||||
@@ -836,7 +878,8 @@ async function route(req, res) {
|
||||
// --- Auth ---
|
||||
if (p === '/api/me' && method === 'GET') {
|
||||
const userCount = db.prepare('SELECT COUNT(*) n FROM users').get().n
|
||||
return json(res, 200, { user: getUser(req), signup: ALLOW_SIGNUP || userCount === 0 })
|
||||
const u = getUser(req)
|
||||
return json(res, 200, { user: u && { ...u, tableV: tableVersion(u.id) }, signup: ALLOW_SIGNUP || userCount === 0 })
|
||||
}
|
||||
if ((p === '/api/login' || p === '/api/register') && method === 'POST') {
|
||||
rateLimit(ip)
|
||||
@@ -873,12 +916,14 @@ async function route(req, res) {
|
||||
|
||||
// Todo lo demás requiere sesión.
|
||||
const user = requireUser(req)
|
||||
if (p === '/api/table') return tableRoute(method, req, res, user, q)
|
||||
// Un libro subido solo existe para quien lo subió, en cualquier ruta y parámetro que lo nombre.
|
||||
let rawUrl = req.url
|
||||
try { rawUrl = decodeURIComponent(req.url) } catch {}
|
||||
for (const [ub] of rawUrl.matchAll(/ub~[0-9a-f]{32}/g)) if (!books.owns(user.id, ub)) throw new HttpError(404, 'No encontrado')
|
||||
if (p.startsWith('/api/bans') && security.route(p, method, req, res, json) !== false) return
|
||||
if ((await features.route(p, method, q, req, res, user)) !== false) return
|
||||
if ((await credits.route(p, method, req, res, user)) !== false) return
|
||||
if ((await upscale.route(p, method, req, res)) !== false) {
|
||||
// Pedir el reescalado (o el automático) lo pone en la biblioteca de quien lo pide.
|
||||
if (method === 'POST' && (m = /^\/api\/upscale\/([^/]+)(\/auto)?$/.exec(p)) && db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(m[1])) linkDownload(user.id, m[1])
|
||||
@@ -948,6 +993,14 @@ async function route(req, res) {
|
||||
}
|
||||
|
||||
let m
|
||||
if ((m = /^\/api\/volumes\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
|
||||
return json(res, 200, { volumes: isBookId(m[1]) ? [] : await volumes.list(m[1], lang) })
|
||||
}
|
||||
if ((m = /^\/api\/tome\/([^/]+)\/([^/]+)\/(front|back)$/.exec(p)) && method === 'GET') {
|
||||
let vol
|
||||
try { vol = decodeURIComponent(m[2]) } catch { throw new HttpError(404, 'Cubierta no encontrada') }
|
||||
return volumes.sendFace(res, m[1], vol, m[3])
|
||||
}
|
||||
if ((m = /^\/api\/manga\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
|
||||
let manga, chapters, offline = false
|
||||
try {
|
||||
@@ -966,7 +1019,7 @@ async function route(req, res) {
|
||||
const reads = db.prepare('SELECT chapter_id, page, total_pages FROM chapter_reads WHERE user_id = ? AND manga_id = ?').all(user.id, m[1])
|
||||
const progress = db.prepare('SELECT * FROM progress WHERE user_id = ? AND manga_id = ?').get(user.id, m[1]) ?? null
|
||||
const chapterLangs = chapters.length || manga.source !== 'md' || offline ? [] : await getChapterLangs(m[1]).catch(() => [])
|
||||
return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true), upscale: upscale.info(m[1]), clips: clips.count(user.id, m[1]), lists: library.listsWith(user.id, m[1]), info: isBookId(m[1]) ? null : meta.cachedInfo(m[1]), clipBanner: clips.bannerFor(user.id, m[1]), ...features.mangaExtras(user.id, manga) })
|
||||
return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, hideCredits: credits.enabled(user.id, m[1]), download: downloadInfo(m[1], true), upscale: upscale.info(m[1]), clips: clips.count(user.id, m[1]), lists: library.listsWith(user.id, m[1]), info: isBookId(m[1]) ? null : meta.cachedInfo(m[1]), clipBanner: clips.bannerFor(user.id, m[1]), ...features.mangaExtras(user.id, manga) })
|
||||
}
|
||||
if (p === '/api/books' && method === 'POST') return json(res, 200, await books.upload(req, user))
|
||||
if ((m = /^\/api\/books\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && m[1].startsWith('ub~') && method === 'DELETE') {
|
||||
@@ -1116,6 +1169,8 @@ const library = setupLibrary({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_
|
||||
// Al volver internet: reanudar las descargas en cola.
|
||||
net.whenOnline(() => pumpDownloads())
|
||||
const meta = setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA, prepareBanner })
|
||||
const credits = setupCredits({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID })
|
||||
const volumes = setupVolumes({ md, sourceOf, fetchMangaFull, stored: meta.stored, mdMatches: meta.mdMatches, muVolumes: meta.muVolumes, similar: meta.similar, miSearch: q => mi.search(q), imgUrl, DATA_DIR, UA, HttpError, net })
|
||||
const clips = setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale })
|
||||
|
||||
// Migración: lo descargado antes de que hubiera dueños pasa a quien lo lee, lo sigue o lo tiene en una
|
||||
|
||||
Reference in New Issue
Block a user