const express = require('express'); const https = require('https'); const path = require('path'); const fs = require('fs'); const app = express(); app.use(express.json()); app.use(express.static(path.join(__dirname, 'public'))); const POINTS_FILE = path.join(__dirname, 'data', 'points.json'); const TILES_DIR = path.join(__dirname, 'tiles'); const SHOM_BASE = 'https://services.data.shom.fr/clevisu/wmts?layer=RASTER_MARINE_3857_WMTS&style=normal&tilematrixset=3857&Service=WMTS&Request=GetTile&Version=1.0.0&Format=image%2Fpng'; const SHOM_HEADERS = { 'Referer': 'https://data.shom.fr', 'Origin': 'https://data.shom.fr', 'User-Agent': 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/114.0', 'Accept': 'image/png' }; let db = null; let mbtilesMeta = {}; let tileStmt = null; async function initMbtiles() { try { const files = fs.readdirSync(TILES_DIR).filter(f => f.endsWith('.mbtiles')); if (files.length === 0) return; const initSqlJs = require('sql.js'); const SQL = await initSqlJs(); const buf = fs.readFileSync(path.join(TILES_DIR, files[0])); db = new SQL.Database(buf); db.each('SELECT name, value FROM metadata', [], (row) => { mbtilesMeta[row.name] = row.value; }); tileStmt = db.prepare( 'SELECT tile_data FROM tiles WHERE zoom_level=? AND tile_column=? AND tile_row=?' ); console.log(`āœ“ MBTiles : ${files[0]} (zoom ${mbtilesMeta.minzoom}–${mbtilesMeta.maxzoom})`); } catch (e) { console.log('Pas de MBTiles local, utilisation du flux SHOM en ligne.'); } } function proxyShom(z, x, y, res) { const url = `${SHOM_BASE}&TileMatrix=${z}&TileCol=${x}&TileRow=${y}`; https.get(url, { headers: SHOM_HEADERS }, (upstream) => { if (upstream.statusCode !== 200) return res.status(upstream.statusCode).end(); res.set('Content-Type', 'image/png'); res.set('Cache-Control', 'public, max-age=86400'); upstream.pipe(res); }).on('error', () => res.status(502).end()); } initMbtiles().then(startServer); app.get('/api/status', (req, res) => { res.json({ hasMbtiles: !!db, name: mbtilesMeta.name || null, minZoom: parseInt(mbtilesMeta.minzoom) || 6, maxZoom: parseInt(mbtilesMeta.maxzoom) || 17 }); }); app.get('/tiles/:z/:x/:y', (req, res) => { const z = parseInt(req.params.z); const x = parseInt(req.params.x); const y = parseInt(req.params.y); if (tileStmt) { const tmsY = (1 << z) - 1 - y; try { tileStmt.bind([z, x, tmsY]); if (!tileStmt.step()) { tileStmt.reset(); return proxyShom(z, x, y, res); } const row = tileStmt.getAsObject(); tileStmt.reset(); const fmt = mbtilesMeta.format || 'png'; res.set('Content-Type', fmt === 'jpg' || fmt === 'jpeg' ? 'image/jpeg' : 'image/png'); res.set('Cache-Control', 'public, max-age=86400'); res.send(Buffer.from(row.tile_data)); } catch (e) { tileStmt.reset(); proxyShom(z, x, y, res); } } else { proxyShom(z, x, y, res); } }); function loadPoints() { if (!fs.existsSync(POINTS_FILE)) return []; try { return JSON.parse(fs.readFileSync(POINTS_FILE, 'utf8')); } catch { return []; } } function savePoints(pts) { fs.writeFileSync(POINTS_FILE, JSON.stringify(pts, null, 2)); } app.get('/api/points', (req, res) => res.json(loadPoints())); app.post('/api/points', (req, res) => { const pts = loadPoints(); const pt = { id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, ...req.body }; pts.push(pt); savePoints(pts); res.json(pt); }); app.put('/api/points/:id', (req, res) => { const pts = loadPoints(); const i = pts.findIndex(p => p.id === req.params.id); if (i === -1) return res.status(404).json({ error: 'Not found' }); pts[i] = { ...pts[i], ...req.body }; savePoints(pts); res.json(pts[i]); }); app.delete('/api/points/:id', (req, res) => { savePoints(loadPoints().filter(p => p.id !== req.params.id)); res.json({ ok: true }); }); function startServer() { app.listen(4242, () => console.log('\nāš“ Saint-Malo Quiz → http://localhost:4242\n')); }