10.6. Membangun dasbor

API JSON kamera baik-baik saja untuk curl, tetapi pemilik ingin mengarahkan ponsel ke URL dan melihat sesuatu. Itu berarti menyajikan HTML, JS, dan CSS dari kamera di samping API.

10.6.1. File statis dari sistem file

microdot.Response.send_file() membaca file dari sistem file kamera dan menulisnya kembali dengan Content-Type yang tepat yang disimpulkan dari ekstensi. Satu rute catch-all di bagian bawah tabel routing mengirim semua yang tidak cocok dengan rute API:

@app.get('/<path:filename>')
async def static(request, filename):
    if '..' in filename or filename.startswith('/'):
        abort(403)
    return Response.send_file('/sdcard/static/' + filename)

Converter <path:filename> dari halaman terakhir cocok dengan segmen yang berisi garis miring -- app.js dan css/site.css keduanya melewatinya. Pemeriksaan kewarasan dua baris menolak path apa pun yang mencoba melarikan diri dari /sdcard/static/ dengan .. atau path absolut -- tanpa itu klien yang penasaran dapat membaca ../../boot.py atau /flash/secrets.txt. send_file() sendiri tidak melakukan sanitasi: ia membuka path apa pun yang Anda berikan.

10.6.2. File dasbor

Tiga file di kamera di bawah /sdcard/static/:

<!-- /sdcard/static/index.html -->
<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Backyard cam</title>
    <link rel="stylesheet" href="style.css">
  </head>
  <body>
    <h1>Backyard cam</h1>
    <img id="stream" src="/stream.jpg">
    <label>Threshold:
      <input id="threshold" type="range" min="0" max="100">
      <span id="threshold-value"></span>
    </label>
    <h2>Recent events</h2>
    <ul id="events"></ul>
    <script src="app.js"></script>
  </body>
</html>
// /sdcard/static/app.js
const slider = document.getElementById('threshold');
const sliderValue = document.getElementById('threshold-value');

async function loadConfig() {
    const r = await fetch('/config');
    const cfg = await r.json();
    slider.value = cfg.threshold;
    sliderValue.textContent = cfg.threshold;
}
loadConfig();

slider.addEventListener('change', async () => {
    sliderValue.textContent = slider.value;
    await fetch('/config', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({threshold: parseInt(slider.value)}),
    });
});
/* /sdcard/static/style.css */
body { font-family: sans-serif; max-width: 720px; margin: 1em auto; }
img#stream { width: 100%; }

HTML memuat stream MJPEG langsung ke dalam <img> -- browser menangani respons multipart secara transparan. Slider membaca ambang batas dari /config saat halaman dimuat dan memposting nilai baru kembali pada setiap perubahan.

10.6.3. Tipe MIME dan kompresi

send_file() membaca ekstensi file dan memilih Content-Type dari microdot.Response.types_map. .html menjadi text/html, .js menjadi text/javascript, .css menjadi text/css, .jpg menjadi image/jpeg. Ekstensi yang telah Anda tambahkan ke dasbor (.svg, .ico) sudah terdaftar. Apa pun yang tidak diketahui akan menggunakan default application/octet-stream -- untuk menambahkan pemetaan baru, perpanjang Response.types_map sekali saat startup.

Untuk aset yang telah Anda kompresi sebelumnya (style.css.gz, app.js.gz) lewatkan compressed=True dan microdot menyajikan file .gz dengan Content-Encoding: gzip. Browser mendekompresi secara transparan dan Anda menghemat beberapa KB pada setiap pemuatan halaman.

Pemilik sekarang membuka http://yard-cam.local/index.html dan melihat umpan langsung, ambang batas saat ini, dan log acara yang kosong.