From 4ae060aa5cf42b9cfdf93ecd8918332396f86d09 Mon Sep 17 00:00:00 2001 From: turer73 <63844106+turer73@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:19:09 +0300 Subject: [PATCH 01/11] Add offline vector map: Protomaps extract + map page, no admin-console map download needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IIAB'ın harita indirmesi (contabostorage) Türkiye'de engelli; README bunun için WARP öneriyor. scripts/download-map.sh Protomaps planet build'inden bölge kesiti (PMTiles) alır, küçük bir MapLibre sayfasını /library/www/html/harita/ altına kurar; nginx dosyaları olduğu gibi sunar (Range desteği hazır), ek ayar yok. Türkiye kesiti ~1,7 GB, z15, Türkçe etiketli. The IIAB map download (contabostorage) is blocked in Turkey and the README suggests WARP for it. scripts/download-map.sh cuts a region (PMTiles) from the Protomaps planet build and installs a small MapLibre page under /library/www/html/harita/; nginx serves the files as-is (Range works out of the box), no extra config. Turkey extract ~1.7 GB, zoom 15, Turkish labels. --- README.md | 10 +- scripts/download-map.sh | 158 ++++++++++++++++++++++++++++ www/harita/index.html | 136 ++++++++++++++++++++++++ www/harita/lib/LICENSE-basemaps.txt | 54 ++++++++++ www/harita/lib/LICENSE-maplibre.txt | 116 ++++++++++++++++++++ www/harita/lib/LICENSE-pmtiles.txt | 19 ++++ www/home/index.html | 1 + 7 files changed, 492 insertions(+), 2 deletions(-) create mode 100755 scripts/download-map.sh create mode 100644 www/harita/index.html create mode 100644 www/harita/lib/LICENSE-basemaps.txt create mode 100644 www/harita/lib/LICENSE-maplibre.txt create mode 100644 www/harita/lib/LICENSE-pmtiles.txt diff --git a/README.md b/README.md index dea4d7c..b7764b7 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ sudo ./scripts/download-content.sh **6. Maps.** Open `http:///admin` (user `iiab-admin`), go to the maps section of the **Install Content** menu and choose the regions you want. +**Alternative, or if that download is blocked (e.g. in Turkey):** `sudo ./scripts/download-map.sh` cuts a vector map of Turkey (about 1.7 GB, down to zoom 15) from the [Protomaps](https://protomaps.com) planet build and installs a small map page. It only talks to `build.protomaps.com`, `github.com` and `cdn.jsdelivr.net`, and nginx serves the files as they are, so there is no extra server config. Open **http://box.lan/harita/**. It works next to the IIAB map, it does not replace it. Another region: `sudo MAP_NAME=germany MAP_BBOX=5.8,47.2,15.1,55.1 ./scripts/download-map.sh` (bbox is `west,south,east,north`). `--update` re-cuts the map when a newer planet build exists. Map data © OpenStreetMap contributors (ODbL). + **7. Use it.** Join the Wi-Fi network you created and open **http://box.lan/home**. ### Optional: internet over a USB Wi-Fi adapter @@ -77,6 +79,7 @@ The Pi's built-in Wi-Fi is dedicated to the hotspot. If the Pi needs internet te ``` install.sh main installer scripts/setup-ssd.sh mounts the SSD at /library +scripts/download-map.sh offline vector map (Protomaps) and its page scripts/download-content.sh downloads/updates ZIM files from Kiwix scripts/install-homepage.sh installs the custom homepage config/iiab-overrides.yml Internet-in-a-Box settings @@ -89,7 +92,7 @@ www/home/index.html homepage - **`ssh user@hostname.local` stops working after install.** Internet-in-a-Box renames the Pi to `box`. Use `ssh user@box.local` or the Pi's IP address. To find the IP, run `arp -a` on your computer or check your router's device list. - **Chromium says the profile is locked.** This also comes from the hostname change. Click "Unlock Profile", or run `rm -f ~/.config/chromium/Singleton*`. - **The hotspot doesn't appear.** Connect over Ethernet, check `sudo iiab-diagnostics`, and make sure the install finished: `/etc/iiab/install-flags/iiab-complete` must exist. -- **Map downloads fail with "connection reset by peer".** The map server (`contabostorage.com`) is blocked in some countries, e.g. Turkey. Install Cloudflare WARP on the Pi, connect, retry the download in the admin console, then disconnect: +- **Map downloads fail with "connection reset by peer".** The map server (`contabostorage.com`) is blocked in some countries, e.g. Turkey. The easy way out is `sudo ./scripts/download-map.sh` (step 6), which does not use that server. Otherwise install Cloudflare WARP on the Pi, connect, retry the download in the admin console, then disconnect: ```bash curl -fsSL https://pkg.cloudflareclient.com/pubkey.gpg | sudo gpg --yes --dearmor -o /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg echo "deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ $(. /etc/os-release; echo $VERSION_CODENAME) main" | sudo tee /etc/apt/sources.list.d/cloudflare-client.list @@ -169,6 +172,8 @@ sudo ./scripts/download-content.sh **6. Haritalar.** `http:///admin` adresine gir (kullanıcı adı `iiab-admin`), **Install Content** menüsündeki harita bölümüne git ve istediğin bölgeleri seç. +**Alternatif, ya da o indirme engelliyse (ör. Türkiye'de):** `sudo ./scripts/download-map.sh` [Protomaps](https://protomaps.com) planet build'inden Türkiye'nin vektör haritasını (yaklaşık 1,7 GB, 15. yakınlaştırma düzeyine kadar) keser ve küçük bir harita sayfası kurar. Yalnızca `build.protomaps.com`, `github.com` ve `cdn.jsdelivr.net` adreslerine gider; dosyaları nginx olduğu gibi sunar, ek sunucu ayarı gerekmez. **http://box.lan/harita/** adresini aç. IIAB haritasının yanında çalışır, onun yerine geçmez. Başka bölge: `sudo MAP_NAME=almanya MAP_BBOX=5.8,47.2,15.1,55.1 ./scripts/download-map.sh` (bbox sırası `batı,güney,doğu,kuzey`). `--update` daha yeni bir planet build'i varsa haritayı yeniden keser. Harita verisi © OpenStreetMap katkıcıları (ODbL). + **7. Kullan.** Oluşturduğun Wi-Fi ağına bağlan ve **http://box.lan/home** adresini aç. ### İsteğe bağlı: USB Wi-Fi adaptörüyle internet @@ -191,6 +196,7 @@ Pi'nin dahili Wi-Fi kartı sadece hotspot için kullanılıyor. Pi'nin geçici o ``` install.sh ana kurulum betiği scripts/setup-ssd.sh SSD'yi /library klasörüne bağlar +scripts/download-map.sh çevrimdışı vektör harita (Protomaps) ve sayfası scripts/download-content.sh Kiwix'ten ZIM dosyalarını indirir/günceller scripts/install-homepage.sh özel ana sayfayı kurar config/iiab-overrides.yml Internet-in-a-Box ayarları @@ -203,7 +209,7 @@ www/home/index.html ana sayfa - **Kurulumdan sonra `ssh kullanici@hostname.local` çalışmıyor.** Internet-in-a-Box cihazın adını `box` yapar. `ssh kullanici@box.local` ya da Pi'nin IP adresini kullan. IP adresini bulmak için bilgisayarında `arp -a` çalıştır ya da modeminin cihaz listesine bak. - **Chromium "profile is locked" hatası veriyor.** Bu da cihaz adının değişmesinden kaynaklanır. "Unlock Profile" butonuna tıkla ya da `rm -f ~/.config/chromium/Singleton*` komutunu çalıştır. - **Hotspot görünmüyor.** Ethernet ile bağlan, `sudo iiab-diagnostics` komutuyla kontrol et ve kurulumun bittiğinden emin ol: `/etc/iiab/install-flags/iiab-complete` dosyası var olmalı. -- **Harita indirmesi "connection reset by peer" hatası veriyor.** Harita sunucusu (`contabostorage.com`) Türkiye gibi bazı ülkelerde engelli. Pi'ye Cloudflare WARP kur, bağlan, indirmeyi yönetim panelinden tekrar başlat ve bitince bağlantıyı kapat: +- **Harita indirmesi "connection reset by peer" hatası veriyor.** Harita sunucusu (`contabostorage.com`) Türkiye gibi bazı ülkelerde engelli. Kolay çıkış yolu `sudo ./scripts/download-map.sh` (adım 6): o sunucuyu kullanmaz. Aksi halde Pi'ye Cloudflare WARP kur, bağlan, indirmeyi yönetim panelinden tekrar başlat ve bitince bağlantıyı kapat: ```bash curl -fsSL https://pkg.cloudflareclient.com/pubkey.gpg | sudo gpg --yes --dearmor -o /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg echo "deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ $(. /etc/os-release; echo $VERSION_CODENAME) main" | sudo tee /etc/apt/sources.list.d/cloudflare-client.list diff --git a/scripts/download-map.sh b/scripts/download-map.sh new file mode 100755 index 0000000..95a6947 --- /dev/null +++ b/scripts/download-map.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# Çevrimdışı vektör harita: Protomaps planet build'inden bölge kesiti (PMTiles) + harita sayfası + yazı tipleri. +# Offline vector map: region extract (PMTiles) from the Protomaps planet build + map page + fonts. +# Her şey /library/www/html/harita/ altına yazılır; Internet-in-a-Box'ın nginx'i düz dosya olarak sunar +# (Range isteklerini kendisi karşılar, ek ayar yok): http://box.lan/harita/ +# Everything is written under /library/www/html/harita/; the IIAB nginx serves it as plain files +# (it handles Range requests itself, no extra config): http://box.lan/harita/ +# +# Neden / why: IIAB'ın harita indirmesi bazı ülkelerde (ör. Türkiye) engelli (README'deki contabostorage notu). +# Bu betik yalnızca build.protomaps.com, github.com ve cdn.jsdelivr.net'e gider. / This script only talks to +# build.protomaps.com, github.com and cdn.jsdelivr.net. +# +# Kullanım / usage: sudo ./scripts/download-map.sh [--update] [--force] +# (seçenek yok) harita yoksa indirir, varsa durumu bildirip çıkar / download if missing, otherwise report and exit +# --update daha yeni planet build'i varsa yeniden keser / re-extract when a newer build exists +# --force her durumda yeniden keser / always re-extract +# Ayarlar (ortam değişkeni / env): MAP_NAME (turkiye), MAP_BBOX (lonW,latS,lonE,latN), MAP_MAXZOOM (15), +# MAP_BUILD (YYYYMMDD; boş = en yeni / empty = latest), WEB (/library/www/html), RESERVE_GB (5) +# Örnek / example (Almanya / Germany): sudo MAP_NAME=almanya MAP_BBOX=5.8,47.2,15.1,55.1 ./scripts/download-map.sh +set -uo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WEB="${WEB:-/library/www/html}" +HARITA="$WEB/harita" +MAPS="$HARITA/data" +LIB="$HARITA/lib" +MAP_NAME="${MAP_NAME:-turkiye}" +MAP_BBOX="${MAP_BBOX:-25.5,35.7,45.0,42.2}" # Türkiye + kıyı/ada payı; ölçülen kesit 1,7 GB (2026-10-31 build, z15) +MAP_MAXZOOM="${MAP_MAXZOOM:-15}" +MAP_BUILD="${MAP_BUILD:-}" +RESERVE_GB="${RESERVE_GB:-5}" +PMTILES_VERSION=1.31.2 +# go-pmtiles yayını checksums dosyası vermiyor; sürüm sabitlenir, tarball sha256'ları burada tutulur (2026-11-01). +# The go-pmtiles release has no checksums file; version is pinned and tarball sha256 values are kept here. +PMTILES_SHA_x86_64=3ed7dbf4ec2e6dfe5e25b6f70d1ffc932729f93c86db353bf514dd71010a312f +PMTILES_SHA_arm64=f8bd47e7ea866863489cad588fbaf2f31f42e5821f7a03f009b3769f05801cb1 +BUILDS_URL=https://build-metadata.protomaps.dev/builds.json +BUILD_BASE=https://build.protomaps.com +ASSETS_URL=https://github.com/protomaps/basemaps-assets/archive/refs/heads/main.tar.gz +JSD=https://cdn.jsdelivr.net/npm +LOCK=/run/lock/harita-indir.lock + +UPDATE=0; FORCE=0 +for a in "$@"; do case "$a" in --update) UPDATE=1;; --force) FORCE=1;; *) echo "bilinmeyen seçenek / unknown option: $a" >&2; exit 2;; esac; done +[ "$EUID" -eq 0 ] || { echo "sudo ile çalıştır / run with sudo" >&2; exit 1; } +[ -d "$WEB" ] || { echo "$WEB yok. Önce Internet-in-a-Box'ı kur / install Internet-in-a-Box first" >&2; exit 1; } +for c in curl tar sha256sum flock; do command -v "$c" >/dev/null || { echo "$c gerekli / required" >&2; exit 1; }; done +exec 9>"$LOCK"; flock -n 9 || { echo "başka bir harita indirmesi çalışıyor / another map download is running ($LOCK)"; exit 0; } +mkdir -p "$MAPS" "$LIB" +failed=0 + +# --- sayfa + kütüphaneler / page + libraries ----------------------------------------------------------- +# Sürümler ve sha256'lar sabit; güncellerken ikisini birlikte değiştir. / Versions and hashes are pinned. +fetch_lib() { # dosya url sha256 + local f="$1" url="$2" sum="$3" + if [ -f "$LIB/$f" ] && echo "$sum $LIB/$f" | sha256sum -c --quiet 2>/dev/null; then return 0; fi + if curl -fsSL -m 300 -o "$LIB/$f.part" "$url" && echo "$sum $LIB/$f.part" | sha256sum -c --quiet; then + mv "$LIB/$f.part" "$LIB/$f" + else + echo "!! $f indirilemedi veya sha256 uyuşmuyor / download failed or sha256 mismatch" >&2 + rm -f "$LIB/$f.part"; failed=1 + fi +} +cp "$DIR/www/harita/index.html" "$HARITA/index.html" +cp "$DIR"/www/harita/lib/LICENSE-*.txt "$LIB/" +fetch_lib maplibre-gl.js "$JSD/maplibre-gl@5.24.0/dist/maplibre-gl.js" 45a9b07a9189ce56054c620a947ccf41e291e58c95e9b61533b740aaa65ee5cb +fetch_lib maplibre-gl.css "$JSD/maplibre-gl@5.24.0/dist/maplibre-gl.css" ab1e70d59ec40465bae7e7030da2f3ccf28133fd502e62bd598eefbadfd7a732 +fetch_lib pmtiles.js "$JSD/pmtiles@4.5.0/dist/pmtiles.js" caf981bc46f6327ee7e65d5dc964d89d38a69f60edca2bd4c5c890c21b554c6c +fetch_lib basemaps.js "$JSD/@protomaps/basemaps@5.7.2/dist/basemaps.js" 551864f01da1bfe3005e99987197f22f56d0c35f9a10fa1727a889e2434af16d +chmod -R a+rX "$HARITA" + +# --- pmtiles CLI (GitHub release, sürüm + sha256 sabit) ------------------------------------------------ +if ! command -v pmtiles >/dev/null; then + case "$(uname -m)" in + x86_64) arch=x86_64; sum=$PMTILES_SHA_x86_64;; + aarch64|arm64) arch=arm64; sum=$PMTILES_SHA_arm64;; + *) echo "!! desteklenmeyen mimari / unsupported arch: $(uname -m)" >&2; exit 1;; + esac + tgz="go-pmtiles_${PMTILES_VERSION}_Linux_${arch}.tar.gz" + tmp=$(mktemp -d) + echo "[$(date -Is)] pmtiles CLI indiriliyor / downloading: $tgz" + if ! curl -fsSL -m 300 -o "$tmp/$tgz" "https://github.com/protomaps/go-pmtiles/releases/download/v${PMTILES_VERSION}/$tgz"; then + echo "!! pmtiles indirilemedi / download failed" >&2; rm -rf "$tmp"; exit 1 + fi + if ! echo "$sum $tmp/$tgz" | sha256sum -c --quiet; then + echo "!! pmtiles sha256 uyuşmuyor, kurulmadı / checksum mismatch, not installed" >&2; rm -rf "$tmp"; exit 1 + fi + tar xzf "$tmp/$tgz" -C "$tmp" pmtiles && install -m 0755 "$tmp/pmtiles" /usr/local/bin/pmtiles + rm -rf "$tmp" +fi +echo "pmtiles: $(pmtiles version 2>&1 | head -1)" + +# --- en yeni planet build / latest planet build -------------------------------------------------------- +if [ -z "$MAP_BUILD" ]; then + MAP_BUILD=$(curl -fsSL -m 60 "$BUILDS_URL" | grep -oE '"key":"[0-9]{8}\.pmtiles"' | grep -oE '[0-9]{8}' | sort | tail -1) + [ -n "$MAP_BUILD" ] || { echo "!! build listesi alınamadı / cannot read $BUILDS_URL" >&2; exit 1; } +fi +OUT="$MAPS/$MAP_NAME.pmtiles"; META="$MAPS/map.json" +have=""; [ -f "$META" ] && have=$(grep -oE '"build": *"[0-9]{8}"' "$META" | grep -oE '[0-9]{8}') + +extract=1 +if [ -f "$OUT" ] && [ "$FORCE" = 0 ]; then + if [ "$UPDATE" = 0 ]; then + echo "== $OUT var (yapı/build ${have:-?}, en yeni/latest $MAP_BUILD); yenilemek için / to refresh: --update"; extract=0 + elif [ -n "$have" ] && [ "$have" -ge "$MAP_BUILD" ]; then + echo "== $OUT zaten en yeni yapı / already latest build ($have)"; extract=0 + fi +fi + +if [ "$extract" = 1 ]; then + avail=$(df -B1 --output=avail "$MAPS" | tail -1 | tr -dc '0-9') + if [ "$avail" -lt $(( (RESERVE_GB + 3) * 1024*1024*1024 )) ]; then + echo "!! $MAPS için yer yok / not enough space (boş/free $((avail/1024/1024)) MB; kesit/extract ~2 GB + $RESERVE_GB GB pay/headroom)" >&2; exit 1 + fi + src="$BUILD_BASE/$MAP_BUILD.pmtiles" + echo "[$(date -Is)] >> $MAP_NAME kesiliyor / extracting: build $MAP_BUILD, bbox $MAP_BBOX, maxzoom $MAP_MAXZOOM (Türkiye ~1,7 GB, 2-10 dk)" + rm -f "$OUT.part" + # İlerleme çubuğu \r ile aynı satırı yeniler; log'a yüzlerce KB çöp yazmasın diye süzülür. + # The progress bar rewrites one line with \r; filter it so logs do not fill with noise. + pmtiles extract "$src" "$OUT.part" --bbox="$MAP_BBOX" --maxzoom="$MAP_MAXZOOM" --download-threads=4 < /dev/null 2>&1 \ + | tr '\r' '\n' | grep -vE '^ *fetching chunks|^[[:space:]]*$' | sed 's/^/ /' + rc=${PIPESTATUS[0]} + if [ "$rc" = 0 ] && pmtiles verify "$OUT.part" >/dev/null 2>&1; then + mv "$OUT.part" "$OUT"; chmod 0644 "$OUT" + bytes=$(stat -c %s "$OUT") + printf '{"name":"%s","build":"%s","bbox":[%s],"maxzoom":%s,"bytes":%s,"updated":"%s","source":"%s"}\n' \ + "$MAP_NAME" "$MAP_BUILD" "$MAP_BBOX" "$MAP_MAXZOOM" "$bytes" "$(date -Is)" "$src" > "$META" + chmod 0644 "$META" + echo "[$(date -Is)] OK $OUT ($((bytes/1024/1024)) MB)" + pmtiles show "$OUT" 2>/dev/null | grep -E 'tile entries|zoom|bounds' | sed 's/^/ /' + else + echo "!! kesit başarısız veya doğrulanamadı, .part silindi / extract failed or did not verify" >&2 + rm -f "$OUT.part"; failed=1 + fi +fi + +# --- yazı tipleri + sprite'lar (basemaps-assets, ~6 MB) / fonts + sprites ------------------------------ +if [ ! -f "$MAPS/assets/sprites/v4/light.json" ] || [ ! -f "$MAPS/assets/fonts/Noto Sans Regular/0-255.pbf" ]; then + tmp=$(mktemp -d) + echo "[$(date -Is)] >> yazı tipi/sprite paketi indiriliyor / downloading fonts and sprites" + if curl -fsSL -m 300 -o "$tmp/assets.tgz" "$ASSETS_URL" \ + && mkdir -p "$MAPS/assets" \ + && tar xzf "$tmp/assets.tgz" -C "$MAPS/assets" --strip-components=1 --wildcards \ + 'basemaps-assets-main/fonts/Noto Sans Regular/*' 'basemaps-assets-main/fonts/Noto Sans Medium/*' \ + 'basemaps-assets-main/fonts/Noto Sans Italic/*' 'basemaps-assets-main/fonts/OFL.txt' \ + 'basemaps-assets-main/sprites/v4/*' 'basemaps-assets-main/README.md'; then + chmod -R a+rX "$MAPS/assets" + echo " OK fonts: $(find "$MAPS/assets/fonts" -name '*.pbf' | wc -l) pbf, sprites: $(ls "$MAPS/assets/sprites/v4" | wc -l) dosya/files" + else + echo "!! paket indirilemedi; etiketler ve simgeler görünmez (tekrar çalıştır) / assets failed, labels and icons will be missing" >&2 + failed=1 + fi + rm -rf "$tmp" +fi + +chmod -R a+rX "$HARITA" +echo "[$(date -Is)] bitti / done (failed=$failed)"; df -h "$MAPS" | tail -1 +[ -f "$OUT" ] && echo "Harita / map: http://box.lan/harita/" +exit $failed diff --git a/www/harita/index.html b/www/harita/index.html new file mode 100644 index 0000000..d9c1f68 --- /dev/null +++ b/www/harita/index.html @@ -0,0 +1,136 @@ + + + + + +Harita :: HAS_PrepperDisk + + + + +
+ « Ana sayfa + Çevrimdışı Harita + + +
+ +
+ + +
+
+
+
+ + + + + + + diff --git a/www/harita/lib/LICENSE-basemaps.txt b/www/harita/lib/LICENSE-basemaps.txt new file mode 100644 index 0000000..8441b08 --- /dev/null +++ b/www/harita/lib/LICENSE-basemaps.txt @@ -0,0 +1,54 @@ +# Code Licenses and Attribution + +# The BSD 3-Clause License (BSD-3-Clause) + +Copyright 2019-2023 Protomaps LLC, Kelso Cartography + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +# Visual Design + +The visual design of the Protomaps basemap styles was created by Geraldine Sarmiento for Protomaps LLC and released under a Creative Commons 0 (CC0) license. + +https://creativecommons.org/publicdomain/zero/1.0/ + +Certain map styles may also include derivatives of icons designed for Mapzen and released under an MIT license. + +https://github.com/tangrams/icons/blob/master/LICENSE.md + +# Tile Schema + +The organization of layers and tags is adapted from the Tilezen project. + +https://github.com/tilezen/vector-datasource +https://github.com/tilezen/vector-datasource/blob/master/docs/LICENSE-SCHEMA.md + +# The MIT License (MIT) + +Copyright (c) 2015-2018 Mapzen +Copyright (c) 2019 Linux Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/www/harita/lib/LICENSE-maplibre.txt b/www/harita/lib/LICENSE-maplibre.txt new file mode 100644 index 0000000..1e8acbb --- /dev/null +++ b/www/harita/lib/LICENSE-maplibre.txt @@ -0,0 +1,116 @@ +Copyright (c) 2023, MapLibre contributors + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * Neither the name of MapLibre GL JS nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +------------------------------------------------------------------------------- + +Contains code from mapbox-gl-js v1.13 and earlier + +Version v1.13 of mapbox-gl-js and earlier are licensed under a BSD-3-Clause license + +Copyright (c) 2020, Mapbox +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +* Neither the name of Mapbox GL JS nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +------------------------------------------------------------------------------- + +Contains code from glfx.js + +Copyright (C) 2011 by Evan Wallace + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +-------------------------------------------------------------------------------- + +Contains a portion of d3-color https://github.com/d3/d3-color + +Copyright 2010-2016 Mike Bostock +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the author nor the names of contributors may be used to + endorse or promote products derived from this software without specific prior + written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/www/harita/lib/LICENSE-pmtiles.txt b/www/harita/lib/LICENSE-pmtiles.txt new file mode 100644 index 0000000..ec3d69a --- /dev/null +++ b/www/harita/lib/LICENSE-pmtiles.txt @@ -0,0 +1,19 @@ +The below license (BSD-3) applies to the reference implementations in this repository. + +The PMTiles specification itself is public domain, or CC0 where applicable. + +Sample tilesets available in this repository are subject to their own license terms. + +--- + +Copyright 2021 Protomaps LLC + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/www/home/index.html b/www/home/index.html index 74a2501..93f74cc 100644 --- a/www/home/index.html +++ b/www/home/index.html @@ -133,6 +133,7 @@ ]}, { title:"Haritalar ve Navigasyon", items:[ { name:"Dünya Haritası", desc:"Avrupa ve Orta Doğu sokak seviyesinde, dünyanın geri kalanı şehir seviyesinde.", lang:"—", size:"~40 GB", url:"/maps" }, + { name:"Çevrimdışı Harita (Protomaps)", desc:"Türkiye vektör haritası, 15. yakınlaştırma düzeyine kadar, Türkçe etiketli. Yüklü değilse sayfa kurulum komutunu gösterir.", lang:"TR", size:"~1,7 GB", url:"/harita/" }, ]}, { title:"Hayatta Kalma ve Hazırlık", items:[ { name:"Afet Sonrası Kitaplığı", desc:"Barınak, enerji, tarım ve yeniden yapılanma üzerine kitaplar.", lang:"EN", size:"~615 MB", zim:"zimgit-post-disaster_en", search:true }, From 328a142f8253a5920a66e50ef7b7c25cc973f755 Mon Sep 17 00:00:00 2001 From: Firewall <149217425+Proxy34@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:59:09 +0300 Subject: [PATCH 02/11] =?UTF-8?q?Add=20approval-based=20automatic=20update?= =?UTF-8?q?s=20/=20Onayl=C4=B1=20otomatik=20g=C3=BCncelleme=20(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the Pi gets internet it checks the Kiwix catalog and the Protomaps build for newer versions; it never downloads on its own. The homepage lists the updates with sizes, warns on metered (phone hotspot) connections, disables items that do not fit on the disk, and anyone on the network can start the download with one button. - scripts/auto-update.sh: --check (rate-limited) and --apply - download-content.sh: CHECK_ONLY mode, reused for the check - update/: unprivileged button endpoint, systemd units, NetworkManager hook, nginx config; scripts/install-auto-update.sh installs them - Builds on #3 and #5. --- README.md | 24 +++++ install.sh | 3 +- scripts/auto-update.sh | 179 ++++++++++++++++++++++++++++++++ scripts/download-content.sh | 4 + scripts/install-auto-update.sh | 55 ++++++++++ update/90-has-update | 11 ++ update/has-update-api.service | 27 +++++ update/has-update-apply.path | 9 ++ update/has-update-apply.service | 10 ++ update/has-update-check.service | 8 ++ update/has-update-check.timer | 14 +++ update/update-api.py | 79 ++++++++++++++ update/update-nginx.conf | 9 ++ www/home/index.html | 90 ++++++++++++++++ 14 files changed, 521 insertions(+), 1 deletion(-) create mode 100755 scripts/auto-update.sh create mode 100755 scripts/install-auto-update.sh create mode 100755 update/90-has-update create mode 100644 update/has-update-api.service create mode 100644 update/has-update-apply.path create mode 100644 update/has-update-apply.service create mode 100644 update/has-update-check.service create mode 100644 update/has-update-check.timer create mode 100644 update/update-api.py create mode 100644 update/update-nginx.conf diff --git a/README.md b/README.md index 8a79107..68ef464 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,15 @@ The Pi's built-in Wi-Fi is dedicated to the hotspot. If the Pi needs internet te **Never run `sudo iiab-network` while a USB Wi-Fi adapter is plugged in.** It may move the hotspot onto the adapter. Unplug it first. +### Automatic updates + +`install.sh` sets this up (on an existing install: `sudo ./scripts/install-auto-update.sh`). When the Pi gets an internet connection (Ethernet, the USB Wi-Fi adapter above, a phone hotspot), it checks which content and map have a newer version. It **never downloads on its own**: the homepage shows the list with sizes, and anyone on the network can pick items and press **Seçilenleri güncelle**. + +- On a phone hotspot or another metered connection the homepage warns that the download uses mobile data. +- If an item does not fit on the disk it cannot be selected; the old version keeps working. +- The check runs at most every 6 hours, plus once a day for boxes that stay online. +- Check now: `sudo ./scripts/auto-update.sh --check --force`. Update from the terminal: `sudo ./scripts/auto-update.sh --apply wikipedia_tr_all_maxi`. Logs: `journalctl -u has-update-check -u has-update-apply` + ### Repo layout ``` @@ -82,6 +91,9 @@ scripts/setup-ssd.sh mounts the SSD at /library scripts/download-map.sh offline vector map (Protomaps) and its page scripts/download-content.sh downloads/updates ZIM files from Kiwix scripts/install-homepage.sh installs the custom homepage +scripts/auto-update.sh checks for and applies updates +scripts/install-auto-update.sh installs the update services +update/ update services, NetworkManager hook, nginx config config/iiab-overrides.yml Internet-in-a-Box settings config/content.conf list of content to download www/home/index.html homepage @@ -191,6 +203,15 @@ Pi'nin dahili Wi-Fi kartı sadece hotspot için kullanılıyor. Pi'nin geçici o **USB Wi-Fi adaptörü takılıyken asla `sudo iiab-network` çalıştırma.** Hotspot'u adaptöre taşıyabilir. Önce adaptörü çıkar. +### Otomatik güncelleme + +`install.sh` bunu kurar (mevcut kurulumda: `sudo ./scripts/install-auto-update.sh`). Pi internete bağlandığında (Ethernet, yukarıdaki USB Wi-Fi adaptörü, telefondan paylaşım) hangi içeriğin ve haritanın yeni sürümü olduğuna bakar. **Kendiliğinden hiçbir şey indirmez:** ana sayfada boyutlarıyla bir liste çıkar, ağa bağlı herkes seçip **Seçilenleri güncelle** butonuna basabilir. + +- Telefondan paylaşım gibi kotalı bir bağlantıdaysa ana sayfa indirmenin mobil veri harcayacağını söyler. +- Diske sığmayan içerik seçilemez; eski sürüm çalışmaya devam eder. +- Kontrol en fazla 6 saatte bir yapılır, sürekli bağlı cihazlarda ayrıca günde bir. +- Hemen kontrol: `sudo ./scripts/auto-update.sh --check --force`. Terminalden güncelleme: `sudo ./scripts/auto-update.sh --apply wikipedia_tr_all_maxi`. Kayıtlar: `journalctl -u has-update-check -u has-update-apply` + ### Repo yapısı ``` @@ -199,6 +220,9 @@ scripts/setup-ssd.sh SSD'yi /library klasörüne bağlar scripts/download-map.sh çevrimdışı vektör harita (Protomaps) ve sayfası scripts/download-content.sh Kiwix'ten ZIM dosyalarını indirir/günceller scripts/install-homepage.sh özel ana sayfayı kurar +scripts/auto-update.sh güncellemeleri kontrol eder ve uygular +scripts/install-auto-update.sh güncelleme servislerini kurar +update/ güncelleme servisleri, NetworkManager kancası, nginx ayarı config/iiab-overrides.yml Internet-in-a-Box ayarları config/content.conf indirilecek içerik listesi www/home/index.html ana sayfa diff --git a/install.sh b/install.sh index 7b975ff..660a890 100755 --- a/install.sh +++ b/install.sh @@ -76,8 +76,9 @@ else fi # -------------------------------------------------------------- 3. ana sayfa -say "3/3 Ana sayfa / homepage" +say "3/3 Ana sayfa ve otomatik güncelleme / homepage and automatic updates" "$REPO/scripts/install-homepage.sh" +"$REPO/scripts/install-auto-update.sh" IP=$(hostname -I | awk '{print $1}') say "Bitti / done" diff --git a/scripts/auto-update.sh b/scripts/auto-update.sh new file mode 100755 index 0000000..41618fd --- /dev/null +++ b/scripts/auto-update.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# Otomatik güncelleme. Kendiliğinden hiçbir şey indirmez: +# --check internet varsa yeni sürümleri bulur, ana sayfanın okuduğu updates.json'a yazar +# --apply ana sayfada onaylanan içerikleri indirir, ilerlemeyi update-status.json'a yazar +# Automatic updates. Never downloads anything on its own: +# --check if online, finds newer versions and writes them to updates.json (read by the homepage) +# --apply downloads the items approved on the homepage, progress goes to update-status.json +# İndirmeyi download-content.sh ve download-map.sh yapar (SHA-256, yer kontrolü, eski sürümü silme). +# The actual downloads are done by download-content.sh and download-map.sh. +# +# Kullanım / usage: +# sudo ./scripts/auto-update.sh --check [--force] --force olmadan 6 saatte birden sık bakmaz / at most every 6 h without --force +# sudo ./scripts/auto-update.sh --apply [id ...] id yoksa ana sayfanın istek dosyasını okur / without ids reads the homepage request +# Ortam / environment: WEB=/library/www/html DEST=/library/zims/content RESERVE_GB=5 CHECK_HOURS=6 +set -uo pipefail + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +CONF="$REPO/config/content.conf" +WEB="${WEB:-/library/www/html}" +DEST="${DEST:-/library/zims/content}" +RESERVE_GB="${RESERVE_GB:-5}" +CHECK_HOURS="${CHECK_HOURS:-6}" +STATE_DIR="${STATE_DIR:-/var/lib/has-update}" +REQUEST="${REQUEST:-/run/has-update/request}" +LOCK="${LOCK:-/run/lock/has-update.lock}" +UPDATES="$WEB/home/updates.json" +STATUS="$WEB/home/update-status.json" +MAP_META="$WEB/harita/data/map.json" +BUILDS_URL=https://build-metadata.protomaps.dev/builds.json +GB=$((1024 * 1024 * 1024)) + +mode=""; FORCE=0; ids=() +for a in "$@"; do + case "$a" in + --check|--apply) mode=$a;; + --force) FORCE=1;; + -*) echo "bilinmeyen seçenek / unknown option: $a" >&2; exit 2;; + *) ids+=("$a");; + esac +done +[ -n "$mode" ] || { echo "kullanım / usage: $0 --check [--force] | --apply [id ...]" >&2; exit 2; } +[ "$EUID" -eq 0 ] || { echo "sudo ile çalıştır / run with sudo" >&2; exit 1; } + +# JSON'a yalnızca bu karakterlerden oluşan değerler yazılır, kaçış gerekmez. +# Only values made of these characters reach the JSON, so no escaping is needed. +safe(){ [[ "$1" =~ ^[A-Za-z0-9._-]+$ ]]; } +write_json(){ cat > "$1.tmp" && chmod 644 "$1.tmp" && mv "$1.tmp" "$1"; } +field(){ sed -nE "s/.*\"$1\":\"?([^,\"}]*).*/\1/p" <<<"$2"; } + +online(){ + local i + for i in 1 2 3; do + curl -fsSI -m 20 -o /dev/null https://download.kiwix.org/zim/ && return 0 + sleep 20 + done + return 1 +} + +# Telefon paylaşımı gibi kotalı bağlantıları NetworkManager "metered" olarak işaretler. +# NetworkManager marks connections such as phone hotspots as metered. +metered(){ + local dev; dev=$(ip route show default 2>/dev/null | awk '{print $5; exit}') + if [ -n "$dev" ] && nmcli -t -g GENERAL.METERED device show "$dev" 2>/dev/null | grep -q '^yes'; then echo true; else echo false; fi +} + +check(){ + if [ "$FORCE" = 0 ] && [ -f "$STATE_DIR/last-check" ] \ + && [ $(( $(date +%s) - $(stat -c %Y "$STATE_DIR/last-check") )) -lt $((CHECK_HOURS * 3600)) ]; then + echo "== son kontrol $CHECK_HOURS saatten yeni, atlandı / checked within $CHECK_HOURS h, skipped"; return 0 + fi + online || { echo "== internet yok, kontrol atlandı / no internet, check skipped"; return 0; } + echo ">> yeni sürümler aranıyor / looking for newer versions" + + local avail items="" sep="" folder prefix latest size current fits + avail=$(df -B1 --output=avail "$DEST" | tail -1 | tr -dc '0-9') + while read -r _ folder prefix latest size; do + safe "$folder" && safe "$prefix" && safe "$latest" || continue + current=$(cd "$DEST" && ls "$prefix"_[0-9][0-9][0-9][0-9]-[0-9][0-9]*.zim 2>/dev/null | tail -1) + [[ "$size" =~ ^[0-9]+$ ]] || size=null + fits=true; [ "$size" != null ] && [ "$avail" -lt $((size + RESERVE_GB * GB)) ] && fits=false + items+="$sep"$'\n'" {\"id\":\"$prefix\",\"kind\":\"zim\",\"folder\":\"$folder\",\"current\":\"$current\",\"latest\":\"$latest\",\"size\":$size,\"fits\":$fits}" + sep="," + done < <(CHECK_ONLY=1 DEST="$DEST" "$REPO/scripts/download-content.sh" "$CONF" | grep '^UPDATE ') + + # Harita yalnızca kuruluysa ve daha yeni planet build'i varsa / map only if installed and a newer build exists + if [ -f "$MAP_META" ]; then + local name have build bytes mapavail + name=$(sed -nE 's/.*"name": *"([^"]*)".*/\1/p' "$MAP_META") + have=$(sed -nE 's/.*"build": *"([0-9]{8})".*/\1/p' "$MAP_META") + bytes=$(sed -nE 's/.*"bytes": *([0-9]+).*/\1/p' "$MAP_META") + build=$(curl -fsSL -m 60 "$BUILDS_URL" | grep -oE '"key":"[0-9]{8}\.pmtiles"' | grep -oE '[0-9]{8}' | sort | tail -1) + if safe "$name" && [ -n "$have" ] && [ -n "$build" ] && [ "$build" -gt "$have" ]; then + # download-map.sh ile aynı kural / same rule as download-map.sh: kesit ~3 GB + pay / extract ~3 GB + headroom + mapavail=$(df -B1 --output=avail "$WEB" | tail -1 | tr -dc '0-9') + fits=true; [ "$mapavail" -lt $(((RESERVE_GB + 3) * GB)) ] && fits=false + items+="$sep"$'\n'" {\"id\":\"harita\",\"kind\":\"map\",\"name\":\"$name\",\"current\":\"$have\",\"latest\":\"$build\",\"size\":${bytes:-null},\"fits\":$fits}" + fi + fi + + write_json "$UPDATES" < $UPDATES" +} + +results="" +status(){ # durum [ek alanlar] / state [extra fields] + write_json "$STATUS" < "$conf" + DEST="$DEST" "$REPO/scripts/download-content.sh" "$conf" & + fi + pid=$! + while kill -0 "$pid" 2>/dev/null; do + # .part bitince yeniden adlandırılır; o zaman son dosyanın boyutu okunur / after the rename read the final file + got=$(stat -c %s "$part" "${part%.part}" 2>/dev/null | head -1) + status running "\"current\":{\"id\":\"$id\",\"done\":${got:-0},\"size\":$size},\"queue\":[$queue]" + sleep 3 + done + wait "$pid"; local rc=$? + [ -n "$conf" ] && rm -f "$conf" + return $rc +} + +apply(){ + if [ ${#ids[@]} -eq 0 ] && [ -f "$REQUEST" ]; then mapfile -t ids < "$REQUEST"; fi + rm -f "$REQUEST" + [ ${#ids[@]} -gt 0 ] || { echo "== güncellenecek bir şey seçilmedi / nothing selected"; return 0; } + [ -f "$UPDATES" ] || { echo "!! önce / run first: $0 --check" >&2; return 1; } + + local i j id line queue failed=0 ok + for i in "${!ids[@]}"; do + id=${ids[$i]} + safe "$id" || { echo "!! geçersiz ad / invalid id: $id" >&2; failed=1; continue; } + queue=""; for j in "${ids[@]:i+1}"; do safe "$j" && queue+="${queue:+,}\"$j\""; done + line=$(grep -F "{\"id\":\"$id\"," "$UPDATES") + if [ -z "$line" ] || [[ "$line" != *'"fits":true'* ]]; then + echo "!! $id güncelleme listesinde yok ya da yer yok / not in the update list or no space" >&2; ok=false + else + echo ">> $id güncelleniyor / updating" + if run_item "$id" "$line" "$queue"; then ok=true; else ok=false; fi + fi + [ "$ok" = true ] || failed=1 + results+="${results:+,}\"$id\":$ok" + done + if [ "$failed" = 0 ]; then status "done" "\"finished\":\"$(date -Is)\""; else status error "\"finished\":\"$(date -Is)\""; fi + FORCE=1 check + return $failed +} + +mkdir -p "$(dirname "$LOCK")" +exec 9>"$LOCK" +if [ "$mode" = --check ]; then + flock -n 9 || { echo "== başka bir güncelleme çalışıyor / another update is running"; exit 0; } + check +else + flock 9 + apply +fi diff --git a/scripts/download-content.sh b/scripts/download-content.sh index 0df02c9..935df27 100755 --- a/scripts/download-content.sh +++ b/scripts/download-content.sh @@ -12,6 +12,8 @@ # # Kullanım / usage: sudo ./scripts/download-content.sh [config-dosyası] # Ortam / environment: DEST=/library/zims/content RESERVE_GB=5 (boş bırakılacak pay / headroom to keep free) +# CHECK_ONLY=1 indirmez, yeni sürümleri "UPDATE " satırı olarak yazar +# downloads nothing, prints newer versions as "UPDATE " (auto-update.sh) set -uo pipefail [ "$EUID" -eq 0 ] || { echo "sudo ile çalıştır / run with sudo" >&2; exit 1; } @@ -56,6 +58,7 @@ while read -r folder prefix _; do # Yer kontrolü / free-space check: kalan boyut + pay / remaining bytes + headroom [ -z "$size" ] && size=$(curl -sIL -m 30 "$url" | grep -i '^content-length:' | tail -1 | tr -dc '0-9') + if [ -n "${CHECK_ONLY:-}" ]; then echo "UPDATE $folder $prefix $latest ${size:-?}"; continue; fi have=0; [ -f "$DEST/$latest.part" ] && have=$(stat -c %s "$DEST/$latest.part") avail=$(df -B1 --output=avail "$DEST" | tail -1 | tr -dc '0-9') if [ -n "$size" ] && [ "$avail" -lt $((size - have + RESERVE_GB * 1024 * 1024 * 1024)) ]; then @@ -91,6 +94,7 @@ while read -r folder prefix _; do [ "$old" != "$DEST/$latest" ] && [ -f "$old" ] && { echo "-- eski sürüm siliniyor / removing $old"; rm -f "$old"; } done done < "$CONF" +[ -n "${CHECK_ONLY:-}" ] && exit $failed echo ">> Kiwix kütüphanesi güncelleniyor / rebuilding Kiwix library" if command -v iiab-make-kiwix-lib >/dev/null; then diff --git a/scripts/install-auto-update.sh b/scripts/install-auto-update.sh new file mode 100755 index 0000000..60b39b5 --- /dev/null +++ b/scripts/install-auto-update.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Otomatik güncellemeyi kurar: internet gelince yeni sürüm kontrolü, ana sayfada bildirim ve +# "Güncelle" butonu. Hiçbir şey onaysız indirilmez. Tekrar çalıştırmak güvenlidir. +# Installs automatic updates: a check for newer versions when internet appears, a homepage notice +# and an update button. Nothing is downloaded without approval. Safe to re-run. +# +# Kullanım / usage: sudo ./scripts/install-auto-update.sh +set -euo pipefail + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +SRC="$REPO/update" +APP=/opt/has-update +NGINX_CONF=/etc/nginx/conf.d/has-update-nginx.conf +UNITS="has-update-check.service has-update-check.timer has-update-apply.service has-update-apply.path has-update-api.service" + +die(){ echo "HATA / ERROR: $*" >&2; exit 1; } +[ "$EUID" -eq 0 ] || die "sudo ile çalıştır / run with sudo: sudo ./scripts/install-auto-update.sh" +[ -d /etc/nginx/conf.d ] || die "nginx bulunamadı. Önce / install first: sudo ./install.sh" + +install -d -m 755 "$APP" +install -m 644 "$SRC/update-api.py" "$APP/" +# Servisler betikleri bu repodan çalıştırır, config/content.conf değişiklikleri de böylece geçerli olur. +# The services run the scripts from this repo, so edits to config/content.conf apply too. +for u in $UNITS; do + sed "s#@REPO@#$REPO#g" "$SRC/$u" > "/etc/systemd/system/$u" + chmod 644 "/etc/systemd/system/$u" +done + +if [ -d /etc/NetworkManager/dispatcher.d ]; then + install -m 755 "$SRC/90-has-update" /etc/NetworkManager/dispatcher.d/90-has-update +else + echo "Uyarı / warning: NetworkManager yok, kontrol yalnızca günde bir yapılır / no NetworkManager, checks run daily only" +fi + +systemctl daemon-reload +systemctl enable --now has-update-api.service has-update-apply.path has-update-check.timer >/dev/null + +install -m 644 "$SRC/update-nginx.conf" "$NGINX_CONF" +if nginx -t 2>/dev/null; then + systemctl reload nginx +else + rm -f "$NGINX_CONF" + nginx -t || true + die "nginx ayarı hatalı, geri alındı / nginx config failed and was removed" +fi + +"$REPO/scripts/install-homepage.sh" +systemctl start --no-block has-update-check.service + +cat < 127.0.0.1:8097 +# Internet-in-a-Box bu klasördeki dosyaları server bloğunun içine ekler. +# Internet-in-a-Box includes files from /etc/nginx/conf.d inside its server block. +location ^~ /guncelleme/ { + proxy_set_header X-Real-IP $remote_addr; + client_max_body_size 4k; + proxy_read_timeout 10s; + proxy_pass http://127.0.0.1:8097/; +} diff --git a/www/home/index.html b/www/home/index.html index af66bad..45d6697 100644 --- a/www/home/index.html +++ b/www/home/index.html @@ -62,6 +62,11 @@ .legend span{display:flex;align-items:center;gap:6px} .legend .fold{margin-top:0;transform:scale(.8)} + /* güncellemeler */ + .warn{color:#B00000;font-weight:bold} + .bar{height:8px;background:#fff;border:1px solid #7F9DB9;margin-top:4px;max-width:400px} + .bar i{display:block;height:100%;background:linear-gradient(#FFA34F,#DD6900)} + .copy{text-align:center;font-size:10px;color:#444;margin:12px 0 4px;line-height:1.6} @media (max-width:640px){ .hide-s{display:none} .logo{font-size:22px} } @@ -92,6 +97,8 @@
+
+
@@ -261,7 +268,90 @@ : `/kiwix/search?pattern=${encodeURIComponent(q)}`; }); +/* Güncellemeler. scripts/auto-update.sh --check yeni sürümleri updates.json'a yazar; butona basınca + seçilenler /guncelleme/ adresine gider, indirme update-status.json'dan izlenir. */ +const getJSON = u => fetch(u, {cache:"no-store"}).then(r => r.ok ? r.json() : null).catch(() => null); +const fmtSize = b => b == null ? "?" : b >= 1e9 ? (b / 1e9).toFixed(1).replace(".", ",") + " GB" : Math.max(1, Math.round(b / 1e6)) + " MB"; +const fmtDate = s => new Date(s).toLocaleString("tr-TR", {dateStyle:"short", timeStyle:"short"}); +function fmtVer(s){ + let m = /_(\d{4})-(\d{2})[a-z]?\.zim$/.exec(s || ""); + if (m) return `${m[2]}/${m[1]}`; + m = /^(\d{4})(\d{2})(\d{2})$/.exec(s || ""); + return m ? `${m[3]}.${m[2]}.${m[1]}` : "yüklü değil"; +} +function itemName(id, items){ + const i = (items || []).find(x => x.id === id) || {}; + if (i.kind === "map") return `Çevrimdışı Harita (${i.name})`; + const c = CATS.flatMap(c => c.items).find(x => x.zim && id.startsWith(x.zim)); + return c ? c.name : id; +} + +async function loadUpdates(){ + const [u, s] = await Promise.all([getJSON("/home/updates.json"), getJSON("/home/update-status.json")]); + const box = document.getElementById("upd"), items = (u && u.items) || []; + let html = ""; + if (s && s.state === "running" && Date.now() - new Date(s.updated) < 120000) { + const c = s.current || {}, pct = c.size ? Math.min(100, Math.floor(c.done * 100 / c.size)) : null; + html = ``; + box.innerHTML = html; box.hidden = false; + setTimeout(loadUpdates, 3000); + return; + } + if (s && s.finished && Date.now() - new Date(s.finished) < 864e5) { + const r = Object.entries(s.results || {}), bad = r.filter(([, ok]) => !ok).map(([id]) => itemName(id, items)); + html += ``; + } + if (items.length) { + html = `` + html; + for (const i of items) html += ` + + + + `; + html += ``; + } else if (html) { + html = `` + html; + } + box.innerHTML = html; box.hidden = !html; + updTotal(); +} + +function updTotal(){ + const el = document.getElementById("upd-total"); + if (!el) return; + const sel = [...document.querySelectorAll("#upd input:checked")]; + el.textContent = sel.length ? `Toplam ${fmtSize(sel.reduce((t, c) => t + +c.dataset.size, 0))}` : ""; +} + +async function startUpdate(){ + const sel = [...document.querySelectorAll("#upd input:checked")]; + if (!sel.length) return; + const total = sel.reduce((t, c) => t + +c.dataset.size, 0); + if (!confirm(`${sel.length} içerik, toplam ${fmtSize(total)} indirilecek. Başlatılsın mı?`)) return; + document.getElementById("upd-go").disabled = true; + try { + const r = await fetch("/guncelleme/", {method:"POST", headers:{"Content-Type":"application/json"}, + body: JSON.stringify({ids: sel.map(c => c.value)})}); + if (!r.ok) alert((await r.json().catch(() => ({}))).error || "Güncelleme başlatılamadı."); + } catch(e) { alert("Güncelleme servisine ulaşılamadı."); } + setTimeout(loadUpdates, 2000); +} + +document.getElementById("upd").addEventListener("change", updTotal); +document.getElementById("upd").addEventListener("click", e => { if (e.target.id === "upd-go") startUpdate(); }); + loadBooks().then(render); +loadUpdates(); From 9a8f1d7082fc8e3b842cf6c2444e8616a7dbb361 Mon Sep 17 00:00:00 2001 From: Firewall <149217425+Proxy34@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:30:55 +0300 Subject: [PATCH 03/11] =?UTF-8?q?Update=20panel:=20pre-select=20only=20wha?= =?UTF-8?q?t=20fits=20together=20/=20Toplamda=20s=C4=B1=C4=9Fanlar=C4=B1?= =?UTF-8?q?=20se=C3=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Varsayılan seçim boş alan ve payla sınırlı; seçim sığmazsa buton kapanır. Yüklü olmayan içerik için 'eski sürüm' uyarısı gösterilmez. --- scripts/auto-update.sh | 2 +- www/home/index.html | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/scripts/auto-update.sh b/scripts/auto-update.sh index 41618fd..9f9ec19 100755 --- a/scripts/auto-update.sh +++ b/scripts/auto-update.sh @@ -98,7 +98,7 @@ check(){ fi write_json "$UPDATES" <` + html; - for (const i of items) html += ` - + // Varsayılan seçim toplamda sığanlarla sınırlı / pre-select only what fits together + let room = u.free - (u.reserve || 0); + for (const i of items) { + const pick = i.fits && (i.size || 0) <= room; + if (pick) room -= i.size || 0; + html += ` + + ${i.fits ? "" : `
Diskte yer yok.${i.current ? " Eski sürüm kullanılmaya devam ediyor." : ""}
`}`; + } + box.dataset.room = u.free - (u.reserve || 0); html += `` + html; // Varsayılan seçim toplamda sığanlarla sınırlı / pre-select only what fits together - let room = u.free - (u.reserve || 0); + let room = disk.free - (disk.reserve || 0); for (const i of items) { const pick = i.fits && (i.size || 0) <= room; if (pick) room -= i.size || 0; @@ -321,11 +323,11 @@ `; } - box.dataset.room = u.free - (u.reserve || 0); + box.dataset.room = disk.free - (disk.reserve || 0); html += ``; + ${u ? ` · Son kontrol: ${esc(fmtDate(u.checked))}` : ""}`; } else if (html) { html = `` + html; } From 27e9de46d60d2d67cecb376379e89ad46349995f Mon Sep 17 00:00:00 2001 From: Firewall <149217425+Proxy34@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:15:09 +0300 Subject: [PATCH 06/11] =?UTF-8?q?Update=20button=20requires=20the=20admin?= =?UTF-8?q?=20password=20/=20G=C3=BCncelleme=20butonu=20y=C3=B6netici=20?= =?UTF-8?q?=C5=9Fifresi=20ister?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kurulumda bir kez sorulur, tuzlu PBKDF2 özeti /etc/has-update/admin'e yazılır ve servise LoadCredential ile verilir; --sifre ile değiştirilir. Yanlış şifre denemeleri sınırlı. Asked once at install; a salted PBKDF2 hash is stored in /etc/has-update/admin and passed to the service with LoadCredential; change it with --sifre. Wrong passwords are rate limited. --- README.md | 4 ++-- scripts/install-auto-update.sh | 21 +++++++++++++++++++- update/has-update-api.service | 1 + update/update-api.py | 36 +++++++++++++++++++++++++++++++--- www/home/index.html | 6 +++++- 5 files changed, 61 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7ce0bc1..5c5e04d 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ The Pi's built-in Wi-Fi is dedicated to the hotspot. If the Pi needs internet te ### Automatic updates -`install.sh` sets this up (on an existing install: `sudo ./scripts/install-auto-update.sh`). When the Pi gets an internet connection (Ethernet, the USB Wi-Fi adapter above, a phone hotspot), it checks which content and map have a newer version. It **never downloads on its own**: the homepage shows the list with sizes, and anyone on the network can pick items and press **Seçilenleri güncelle**. +`install.sh` sets this up (on an existing install: `sudo ./scripts/install-auto-update.sh`). When the Pi gets an internet connection (Ethernet, the USB Wi-Fi adapter above, a phone hotspot), it checks which content and map have a newer version. It **never downloads on its own**: the homepage shows the list with sizes; pick items, enter the admin password and press **Seçilenleri güncelle**. The installer asks for this password once; change it with `sudo ./scripts/install-auto-update.sh --sifre`. - On a phone hotspot or another metered connection the homepage warns that the download uses mobile data. - If an item does not fit on the disk it cannot be selected; the old version keeps working. @@ -207,7 +207,7 @@ Pi'nin dahili Wi-Fi kartı sadece hotspot için kullanılıyor. Pi'nin geçici o ### Otomatik güncelleme -`install.sh` bunu kurar (mevcut kurulumda: `sudo ./scripts/install-auto-update.sh`). Pi internete bağlandığında (Ethernet, yukarıdaki USB Wi-Fi adaptörü, telefondan paylaşım) hangi içeriğin ve haritanın yeni sürümü olduğuna bakar. **Kendiliğinden hiçbir şey indirmez:** ana sayfada boyutlarıyla bir liste çıkar, ağa bağlı herkes seçip **Seçilenleri güncelle** butonuna basabilir. +`install.sh` bunu kurar (mevcut kurulumda: `sudo ./scripts/install-auto-update.sh`). Pi internete bağlandığında (Ethernet, yukarıdaki USB Wi-Fi adaptörü, telefondan paylaşım) hangi içeriğin ve haritanın yeni sürümü olduğuna bakar. **Kendiliğinden hiçbir şey indirmez:** ana sayfada boyutlarıyla bir liste çıkar; içerikleri seçip yönetici şifresini girerek **Seçilenleri güncelle** butonuna basılır. Şifre kurulumda bir kez sorulur; değiştirmek için `sudo ./scripts/install-auto-update.sh --sifre`. - Telefondan paylaşım gibi kotalı bir bağlantıdaysa ana sayfa indirmenin mobil veri harcayacağını söyler. - Diske sığmayan içerik seçilemez; eski sürüm çalışmaya devam eder. diff --git a/scripts/install-auto-update.sh b/scripts/install-auto-update.sh index ae96321..656b613 100755 --- a/scripts/install-auto-update.sh +++ b/scripts/install-auto-update.sh @@ -4,13 +4,17 @@ # Installs automatic updates: a check for newer versions when internet appears, a homepage notice # and an update button. Nothing is downloaded without approval. Safe to re-run. # -# Kullanım / usage: sudo ./scripts/install-auto-update.sh +# Güncelleme butonu yönetici şifresi ister; ilk kurulumda sorulur, --sifre ile değiştirilir. +# The update button asks for an admin password; set on first install, change it with --sifre. +# +# Kullanım / usage: sudo ./scripts/install-auto-update.sh [--sifre] set -euo pipefail REPO="$(cd "$(dirname "$0")/.." && pwd)" SRC="$REPO/update" APP=/opt/has-update NGINX_CONF=/etc/nginx/conf.d/has-update-nginx.conf +ADMIN=/etc/has-update/admin UNITS="has-update-check.service has-update-check.timer has-update-apply.service has-update-apply.path has-update-api.service has-usb@.service" die(){ echo "HATA / ERROR: $*" >&2; exit 1; } @@ -36,6 +40,21 @@ install -m 644 "$SRC/99-has-usb.rules" /etc/udev/rules.d/99-has-usb.rules udevadm control --reload ln -sfn /run/has-usb/usb.json /library/www/html/home/usb.json +# Şifrenin kendisi değil, tuzlu özeti saklanır / only a salted hash is stored +if [ ! -s "$ADMIN" ] || [ "${1:-}" = --sifre ]; then + while :; do + read -rsp "Güncelleme butonu yönetici şifresi / update button admin password (en az 8 / min 8): " P1; echo + read -rsp "Tekrar / again: " P2; echo + [ "${#P1}" -ge 8 ] && [ "$P1" = "$P2" ] && break + echo "Kısa ya da eşleşmiyor / too short or does not match" + done + install -d -m 700 /etc/has-update + printf %s "$P1" | python3 -c 'import hashlib, os, sys +salt = os.urandom(16) +print(salt.hex(), hashlib.pbkdf2_hmac("sha256", sys.stdin.buffer.read(), salt, 200000).hex())' > "$ADMIN.tmp" + chmod 600 "$ADMIN.tmp" && mv "$ADMIN.tmp" "$ADMIN" +fi + systemctl daemon-reload systemctl enable --now has-update-api.service has-update-apply.path has-update-check.timer >/dev/null systemctl restart has-update-api.service diff --git a/update/has-update-api.service b/update/has-update-api.service index 13e2d43..fca8ed4 100644 --- a/update/has-update-api.service +++ b/update/has-update-api.service @@ -7,6 +7,7 @@ RequiresMountsFor=/library Type=simple DynamicUser=yes RuntimeDirectory=has-update +LoadCredential=admin:/etc/has-update/admin Environment=PYTHONUNBUFFERED=1 ExecStart=/usr/bin/python3 /opt/has-update/update-api.py Restart=always diff --git a/update/update-api.py b/update/update-api.py index 559a8be..051dfe9 100644 --- a/update/update-api.py +++ b/update/update-api.py @@ -1,17 +1,21 @@ #!/usr/bin/env python3 """HAS_PrepperDisk güncelleme butonu: POST http://box.lan/guncelleme/ {"ids": [...]} -Ana sayfadaki "Güncelle" butonu buraya istek atar. Servis yetkisiz bir kullanıcıyla çalışır ve +Ana sayfadaki "Güncelle" butonu buraya istek atar; yönetici şifresi gerekir. Servis yetkisiz bir kullanıcıyla çalışır ve hiçbir şey indirmez: seçimi updates.json ve usb.json'a göre denetleyip istek dosyasına yazar, indirmeyi has-update-apply.path (root) başlatır. -The homepage "Güncelle" button posts here. The service runs unprivileged and downloads nothing: +The homepage "Güncelle" button posts here; the admin password is required. The service runs unprivileged and downloads nothing: it checks the selection against updates.json and usb.json and writes the request file; has-update-apply.path (root) starts the download. """ +import hashlib +import hmac import json import os +import threading import time +from collections import defaultdict from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer WEB = os.environ.get("WEB", "/library/www/html") @@ -20,6 +24,13 @@ STATUS = os.path.join(WEB, "home", "update-status.json") REQUEST = os.environ.get("REQUEST", "/run/has-update/request") PORT = int(os.environ.get("PORT", "8097")) +# Şifrenin tuzlu PBKDF2 özeti; systemd LoadCredential ile gelir. / salted PBKDF2 hash of the password, via LoadCredential. +ADMIN = os.path.join(os.environ.get("CREDENTIALS_DIRECTORY", "/etc/has-update"), "admin") +# Aynı cihazdan 10 dakikada en fazla 5 yanlış şifre / at most 5 wrong passwords per device per 10 minutes +RATE_N, RATE_WINDOW = 5, 600 + +lock = threading.Lock() +fails = defaultdict(list) def read_json(path): @@ -30,6 +41,15 @@ def read_json(path): return {} +def password_ok(pw): + try: + with open(ADMIN, encoding="utf-8") as f: + salt, h = f.read().split() + except (OSError, ValueError): + return False + return hmac.compare_digest(hashlib.pbkdf2_hmac("sha256", pw.encode(), bytes.fromhex(salt), 200000).hex(), h) + + def running(): # auto-update.sh indirirken durumu 3 sn'de bir yazar; 2 dk sessizse yarıda kalmıştır. # auto-update.sh rewrites the status every 3 s while downloading; 2 min of silence means it died. @@ -58,9 +78,19 @@ def do_POST(self): return self._send(415, {"error": "application/json gerekli / required"}) try: length = min(int(self.headers.get("Content-Length", 0)), 4096) - ids = json.loads(self.rfile.read(length))["ids"] + data = json.loads(self.rfile.read(length)) + ids, pw = data["ids"], data.get("sifre") except (ValueError, KeyError, TypeError): return self._send(400, {"error": "Geçersiz istek / bad request"}) + ip, now = self.headers.get("X-Real-IP") or self.client_address[0], time.time() + with lock: + fails[ip] = [t for t in fails[ip] if now - t < RATE_WINDOW] + if len(fails[ip]) >= RATE_N: + return self._send(429, {"error": "Çok fazla yanlış şifre, biraz bekleyin / too many wrong passwords, wait a bit"}) + if not isinstance(pw, str) or not password_ok(pw): + with lock: + fails[ip].append(now) + return self._send(403, {"error": "Şifre yanlış / wrong password"}) if running(): return self._send(409, {"error": "Güncelleme zaten sürüyor / an update is already running"}) items = read_json(UPDATES).get("items", []) + read_json(USB).get("items", []) diff --git a/www/home/index.html b/www/home/index.html index ebac9b8..d267614 100644 --- a/www/home/index.html +++ b/www/home/index.html @@ -55,6 +55,7 @@ .search td{background:#EFEFEF} .search input[type=text],.search select{font-family:Verdana,sans-serif;font-size:11px;border:1px solid #7F9DB9;padding:2px;background:#fff} .search input[type=text]{width:220px;max-width:100%} + #upd-pw{font-family:Verdana,sans-serif;font-size:11px;border:1px solid #7F9DB9;padding:2px;width:130px} .btn{font-family:Verdana,sans-serif;font-size:11px;font-weight:bold;background:#FAFAFA;border:1px solid #000;padding:1px 6px;cursor:pointer} .btn:hover{background:#FFF3D6} @@ -326,6 +327,7 @@ box.dataset.room = disk.free - (disk.reserve || 0); html += ``; } else if (html) { @@ -347,13 +349,15 @@ async function startUpdate(){ const sel = [...document.querySelectorAll("#upd input:checked")]; + const pw = document.getElementById("upd-pw").value; if (!sel.length) return; + if (!pw) { alert("Güncellemek için yönetici şifresini girin."); return; } const total = sel.reduce((t, c) => t + +c.dataset.size, 0); if (!confirm(`${sel.length} içerik, toplam ${fmtSize(total)} indirilecek. Başlatılsın mı?`)) return; document.getElementById("upd-go").disabled = true; try { const r = await fetch("/guncelleme/", {method:"POST", headers:{"Content-Type":"application/json"}, - body: JSON.stringify({ids: sel.map(c => c.value)})}); + body: JSON.stringify({ids: sel.map(c => c.value), sifre: pw})}); if (!r.ok) alert((await r.json().catch(() => ({}))).error || "Güncelleme başlatılamadı."); } catch(e) { alert("Güncelleme servisine ulaşılamadı."); } setTimeout(loadUpdates, 2000); From 6f0017b7688b4c9469ad9c48cb8073362a801026 Mon Sep 17 00:00:00 2001 From: Firewall <149217425+Proxy34@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:17:05 +0300 Subject: [PATCH 07/11] =?UTF-8?q?Update=20button=20uses=20the=20iiab-admin?= =?UTF-8?q?=20password=20/=20G=C3=BCncelleme=20butonu=20iiab-admin=20?= =?UTF-8?q?=C5=9Fifresini=20kullan=C4=B1r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ayrı şifre yerine kurulumda belirlenen iiab-admin şifresi. Servis yetkisiz kalır: başlarken root bir adım yalnızca iiab-admin'in /etc/shadow özetini /run/has-update/admin'e koyar, şifre değişince has-update-admin.path yeniler. yescrypt'i sistemin libcrypt'i doğrular. One password instead of two. The service stays unprivileged: a root start step copies only iiab-admin's hash from /etc/shadow, has-update-admin.path refreshes it on change. --- README.md | 4 ++-- scripts/install-auto-update.sh | 26 +++++--------------------- update/admin-hash.sh | 10 ++++++++++ update/has-update-admin.path | 9 +++++++++ update/has-update-admin.service | 6 ++++++ update/has-update-api.service | 2 +- update/update-api.py | 28 ++++++++++++++++++++-------- www/home/index.html | 4 ++-- 8 files changed, 55 insertions(+), 34 deletions(-) create mode 100755 update/admin-hash.sh create mode 100644 update/has-update-admin.path create mode 100644 update/has-update-admin.service diff --git a/README.md b/README.md index 5c5e04d..93ce120 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ The Pi's built-in Wi-Fi is dedicated to the hotspot. If the Pi needs internet te ### Automatic updates -`install.sh` sets this up (on an existing install: `sudo ./scripts/install-auto-update.sh`). When the Pi gets an internet connection (Ethernet, the USB Wi-Fi adapter above, a phone hotspot), it checks which content and map have a newer version. It **never downloads on its own**: the homepage shows the list with sizes; pick items, enter the admin password and press **Seçilenleri güncelle**. The installer asks for this password once; change it with `sudo ./scripts/install-auto-update.sh --sifre`. +`install.sh` sets this up (on an existing install: `sudo ./scripts/install-auto-update.sh`). When the Pi gets an internet connection (Ethernet, the USB Wi-Fi adapter above, a phone hotspot), it checks which content and map have a newer version. It **never downloads on its own**: the homepage shows the list with sizes; pick items, enter the `iiab-admin` password (the one set during install, also used for `/admin`) and press **Seçilenleri güncelle**. - On a phone hotspot or another metered connection the homepage warns that the download uses mobile data. - If an item does not fit on the disk it cannot be selected; the old version keeps working. @@ -207,7 +207,7 @@ Pi'nin dahili Wi-Fi kartı sadece hotspot için kullanılıyor. Pi'nin geçici o ### Otomatik güncelleme -`install.sh` bunu kurar (mevcut kurulumda: `sudo ./scripts/install-auto-update.sh`). Pi internete bağlandığında (Ethernet, yukarıdaki USB Wi-Fi adaptörü, telefondan paylaşım) hangi içeriğin ve haritanın yeni sürümü olduğuna bakar. **Kendiliğinden hiçbir şey indirmez:** ana sayfada boyutlarıyla bir liste çıkar; içerikleri seçip yönetici şifresini girerek **Seçilenleri güncelle** butonuna basılır. Şifre kurulumda bir kez sorulur; değiştirmek için `sudo ./scripts/install-auto-update.sh --sifre`. +`install.sh` bunu kurar (mevcut kurulumda: `sudo ./scripts/install-auto-update.sh`). Pi internete bağlandığında (Ethernet, yukarıdaki USB Wi-Fi adaptörü, telefondan paylaşım) hangi içeriğin ve haritanın yeni sürümü olduğuna bakar. **Kendiliğinden hiçbir şey indirmez:** ana sayfada boyutlarıyla bir liste çıkar; içerikleri seçip `iiab-admin` şifresini (kurulumda belirlenen, `/admin` girişinde de kullanılan şifre) girerek **Seçilenleri güncelle** butonuna basılır. - Telefondan paylaşım gibi kotalı bir bağlantıdaysa ana sayfa indirmenin mobil veri harcayacağını söyler. - Diske sığmayan içerik seçilemez; eski sürüm çalışmaya devam eder. diff --git a/scripts/install-auto-update.sh b/scripts/install-auto-update.sh index 656b613..bdfb4a2 100755 --- a/scripts/install-auto-update.sh +++ b/scripts/install-auto-update.sh @@ -4,18 +4,16 @@ # Installs automatic updates: a check for newer versions when internet appears, a homepage notice # and an update button. Nothing is downloaded without approval. Safe to re-run. # -# Güncelleme butonu yönetici şifresi ister; ilk kurulumda sorulur, --sifre ile değiştirilir. -# The update button asks for an admin password; set on first install, change it with --sifre. +# Güncelleme butonu iiab-admin şifresini ister / the update button asks for the iiab-admin password. # -# Kullanım / usage: sudo ./scripts/install-auto-update.sh [--sifre] +# Kullanım / usage: sudo ./scripts/install-auto-update.sh set -euo pipefail REPO="$(cd "$(dirname "$0")/.." && pwd)" SRC="$REPO/update" APP=/opt/has-update NGINX_CONF=/etc/nginx/conf.d/has-update-nginx.conf -ADMIN=/etc/has-update/admin -UNITS="has-update-check.service has-update-check.timer has-update-apply.service has-update-apply.path has-update-api.service has-usb@.service" +UNITS="has-update-check.service has-update-check.timer has-update-apply.service has-update-apply.path has-update-api.service has-update-admin.service has-update-admin.path has-usb@.service" die(){ echo "HATA / ERROR: $*" >&2; exit 1; } [ "$EUID" -eq 0 ] || die "sudo ile çalıştır / run with sudo: sudo ./scripts/install-auto-update.sh" @@ -23,6 +21,7 @@ die(){ echo "HATA / ERROR: $*" >&2; exit 1; } install -d -m 755 "$APP" install -m 644 "$SRC/update-api.py" "$APP/" +install -m 755 "$SRC/admin-hash.sh" "$APP/" # Servisler betikleri bu repodan çalıştırır, config/content.conf değişiklikleri de böylece geçerli olur. # The services run the scripts from this repo, so edits to config/content.conf apply too. for u in $UNITS; do @@ -40,23 +39,8 @@ install -m 644 "$SRC/99-has-usb.rules" /etc/udev/rules.d/99-has-usb.rules udevadm control --reload ln -sfn /run/has-usb/usb.json /library/www/html/home/usb.json -# Şifrenin kendisi değil, tuzlu özeti saklanır / only a salted hash is stored -if [ ! -s "$ADMIN" ] || [ "${1:-}" = --sifre ]; then - while :; do - read -rsp "Güncelleme butonu yönetici şifresi / update button admin password (en az 8 / min 8): " P1; echo - read -rsp "Tekrar / again: " P2; echo - [ "${#P1}" -ge 8 ] && [ "$P1" = "$P2" ] && break - echo "Kısa ya da eşleşmiyor / too short or does not match" - done - install -d -m 700 /etc/has-update - printf %s "$P1" | python3 -c 'import hashlib, os, sys -salt = os.urandom(16) -print(salt.hex(), hashlib.pbkdf2_hmac("sha256", sys.stdin.buffer.read(), salt, 200000).hex())' > "$ADMIN.tmp" - chmod 600 "$ADMIN.tmp" && mv "$ADMIN.tmp" "$ADMIN" -fi - systemctl daemon-reload -systemctl enable --now has-update-api.service has-update-apply.path has-update-check.timer >/dev/null +systemctl enable --now has-update-api.service has-update-apply.path has-update-admin.path has-update-check.timer >/dev/null systemctl restart has-update-api.service install -m 644 "$SRC/update-nginx.conf" "$NGINX_CONF" diff --git a/update/admin-hash.sh b/update/admin-hash.sh new file mode 100755 index 0000000..02b2bb9 --- /dev/null +++ b/update/admin-hash.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# Yalnızca iiab-admin şifresinin özetini buton servisinin klasörüne koyar; servis /etc/shadow'u okuyamaz. +# Copies only the iiab-admin password hash into the button service's directory; the service can't read /etc/shadow. +set -eu +D=/run/has-update +[ -d "$D" ] || exit 0 +getent shadow iiab-admin | cut -d: -f2 > "$D/admin.tmp" +chown --reference="$D" "$D/admin.tmp" +chmod 600 "$D/admin.tmp" +mv "$D/admin.tmp" "$D/admin" diff --git a/update/has-update-admin.path b/update/has-update-admin.path new file mode 100644 index 0000000..c5497f2 --- /dev/null +++ b/update/has-update-admin.path @@ -0,0 +1,9 @@ +[Unit] +Description=HAS_PrepperDisk: iiab-admin şifresi değişince güncelleme butonuna bildir / pass a changed iiab-admin password to the update button + +[Path] +PathChanged=/etc/shadow +Unit=has-update-admin.service + +[Install] +WantedBy=paths.target diff --git a/update/has-update-admin.service b/update/has-update-admin.service new file mode 100644 index 0000000..06b566c --- /dev/null +++ b/update/has-update-admin.service @@ -0,0 +1,6 @@ +[Unit] +Description=HAS_PrepperDisk: iiab-admin şifre özetini yenile / refresh the iiab-admin password hash + +[Service] +Type=oneshot +ExecStart=/opt/has-update/admin-hash.sh diff --git a/update/has-update-api.service b/update/has-update-api.service index fca8ed4..13ba63d 100644 --- a/update/has-update-api.service +++ b/update/has-update-api.service @@ -7,7 +7,7 @@ RequiresMountsFor=/library Type=simple DynamicUser=yes RuntimeDirectory=has-update -LoadCredential=admin:/etc/has-update/admin +ExecStartPre=+/opt/has-update/admin-hash.sh Environment=PYTHONUNBUFFERED=1 ExecStart=/usr/bin/python3 /opt/has-update/update-api.py Restart=always diff --git a/update/update-api.py b/update/update-api.py index 051dfe9..5dec369 100644 --- a/update/update-api.py +++ b/update/update-api.py @@ -1,15 +1,16 @@ #!/usr/bin/env python3 """HAS_PrepperDisk güncelleme butonu: POST http://box.lan/guncelleme/ {"ids": [...]} -Ana sayfadaki "Güncelle" butonu buraya istek atar; yönetici şifresi gerekir. Servis yetkisiz bir kullanıcıyla çalışır ve +Ana sayfadaki "Güncelle" butonu buraya istek atar; iiab-admin şifresi gerekir. Servis yetkisiz bir kullanıcıyla çalışır ve hiçbir şey indirmez: seçimi updates.json ve usb.json'a göre denetleyip istek dosyasına yazar, indirmeyi has-update-apply.path (root) başlatır. -The homepage "Güncelle" button posts here; the admin password is required. The service runs unprivileged and downloads nothing: +The homepage "Güncelle" button posts here; the iiab-admin password is required. The service runs unprivileged and downloads nothing: it checks the selection against updates.json and usb.json and writes the request file; has-update-apply.path (root) starts the download. """ -import hashlib +import ctypes +import ctypes.util import hmac import json import os @@ -24,14 +25,20 @@ STATUS = os.path.join(WEB, "home", "update-status.json") REQUEST = os.environ.get("REQUEST", "/run/has-update/request") PORT = int(os.environ.get("PORT", "8097")) -# Şifrenin tuzlu PBKDF2 özeti; systemd LoadCredential ile gelir. / salted PBKDF2 hash of the password, via LoadCredential. -ADMIN = os.path.join(os.environ.get("CREDENTIALS_DIRECTORY", "/etc/has-update"), "admin") +# iiab-admin şifresinin /etc/shadow'daki özeti; admin-hash.sh yazar. / iiab-admin's hash from /etc/shadow, written by admin-hash.sh. +ADMIN = os.path.join(os.path.dirname(REQUEST), "admin") # Aynı cihazdan 10 dakikada en fazla 5 yanlış şifre / at most 5 wrong passwords per device per 10 minutes RATE_N, RATE_WINDOW = 5, 600 lock = threading.Lock() fails = defaultdict(list) +# Python 3.13'te crypt modülü yok; yescrypt özetini sistemin libcrypt'i doğrular. +# Python 3.13 has no crypt module; the system libcrypt checks the yescrypt hash. +libcrypt = ctypes.CDLL(ctypes.util.find_library("crypt")) +libcrypt.crypt.restype = ctypes.c_char_p +libcrypt.crypt.argtypes = (ctypes.c_char_p, ctypes.c_char_p) + def read_json(path): try: @@ -44,10 +51,15 @@ def read_json(path): def password_ok(pw): try: with open(ADMIN, encoding="utf-8") as f: - salt, h = f.read().split() - except (OSError, ValueError): + h = f.read().strip() + except OSError: + return False + # Şifresi kilitli ya da boş hesap ("!", "*", "") hiçbir şifreyle açılmaz / locked or empty accounts never match + if not h.startswith("$"): return False - return hmac.compare_digest(hashlib.pbkdf2_hmac("sha256", pw.encode(), bytes.fromhex(salt), 200000).hex(), h) + with lock: + r = libcrypt.crypt(pw.encode(), h.encode()) + return bool(r) and hmac.compare_digest(r.decode(), h) def running(): diff --git a/www/home/index.html b/www/home/index.html index d267614..4334ace 100644 --- a/www/home/index.html +++ b/www/home/index.html @@ -327,7 +327,7 @@ box.dataset.room = disk.free - (disk.reserve || 0); html += ``; } else if (html) { @@ -351,7 +351,7 @@ const sel = [...document.querySelectorAll("#upd input:checked")]; const pw = document.getElementById("upd-pw").value; if (!sel.length) return; - if (!pw) { alert("Güncellemek için yönetici şifresini girin."); return; } + if (!pw) { alert("Güncellemek için iiab-admin şifresini girin (yönetim paneli şifresi)."); return; } const total = sel.reduce((t, c) => t + +c.dataset.size, 0); if (!confirm(`${sel.length} içerik, toplam ${fmtSize(total)} indirilecek. Başlatılsın mı?`)) return; document.getElementById("upd-go").disabled = true; From 29dc98d2c004ffba175aca939b604aa504177ebd Mon Sep 17 00:00:00 2001 From: Firewall <149217425+Proxy34@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:32:24 +0300 Subject: [PATCH 08/11] =?UTF-8?q?Map=20update=20check=20works=20when=20bui?= =?UTF-8?q?lds.json=20is=20unreachable=20/=20builds.json=20a=C3=A7=C4=B1lm?= =?UTF-8?q?azsa=20da=20harita=20g=C3=BCncellemesi=20bulunur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/auto-update.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/auto-update.sh b/scripts/auto-update.sh index 6ae7a90..002908b 100755 --- a/scripts/auto-update.sh +++ b/scripts/auto-update.sh @@ -34,6 +34,7 @@ USB_DIR="${USB_DIR:-/run/has-usb}" USB_JSON="$USB_DIR/usb.json" USB_ROOTS="${USB_ROOTS:-$USB_DIR /media}" BUILDS_URL=https://build-metadata.protomaps.dev/builds.json +BUILD_BASE=https://build.protomaps.com GB=$((1024 * 1024 * 1024)) mode=""; FORCE=0; ids=(); usbact=""; usbdev="" @@ -92,11 +93,19 @@ check(){ # Harita yalnızca kuruluysa ve daha yeni planet build'i varsa / map only if installed and a newer build exists if [ -f "$MAP_META" ]; then - local name have build bytes mapavail + local name have build bytes mapavail i d name=$(sed -nE 's/.*"name": *"([^"]*)".*/\1/p' "$MAP_META") have=$(sed -nE 's/.*"build": *"([0-9]{8})".*/\1/p' "$MAP_META") bytes=$(sed -nE 's/.*"bytes": *([0-9]+).*/\1/p' "$MAP_META") build=$(curl -fsSL -m 60 "$BUILDS_URL" | grep -oE '"key":"[0-9]{8}\.pmtiles"' | grep -oE '[0-9]{8}' | sort | tail -1) + # builds.json bazı bağlantılardan açılmıyor; o zaman son günlerin dosyası doğrudan sorulur. + # builds.json is unreachable from some connections; then ask for recent daily files directly. + if [ -z "$build" ]; then + for i in $(seq 0 10); do + d=$(date -u -d "-$i day" +%Y%m%d) + curl -fsI -m 20 -o /dev/null "$BUILD_BASE/$d.pmtiles" && { build=$d; break; } + done + fi if safe "$name" && [ -n "$have" ] && [ -n "$build" ] && [ "$build" -gt "$have" ]; then # download-map.sh ile aynı kural / same rule as download-map.sh: kesit ~3 GB + pay / extract ~3 GB + headroom mapavail=$(df -B1 --output=avail "$WEB" | tail -1 | tr -dc '0-9') From fdd057fd6481ae4bb4d6c668a04a508fe87ddf2b Mon Sep 17 00:00:00 2001 From: Firewall <149217425+Proxy34@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:36:01 +0300 Subject: [PATCH 09/11] =?UTF-8?q?Fold=20content=20the=20box=20never=20had?= =?UTF-8?q?=20in=20the=20update=20list=20/=20Kutuda=20olmayan=20i=C3=A7eri?= =?UTF-8?q?kler=20g=C3=BCncelleme=20listesinde=20kapal=C4=B1=20durur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- www/home/index.html | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/www/home/index.html b/www/home/index.html index 4334ace..0017a97 100644 --- a/www/home/index.html +++ b/www/home/index.html @@ -311,13 +311,23 @@ ${r.length - bad.length} içerik güncellendi${bad.length ? `, ${bad.length} tanesi başarısız: ${esc(bad.join(", "))}` : "."}`; } if (items.length) { - html = `` + html; + /* Kutuda hiç olmayan içerikler (yeni kurulumda hepsi) listeyi doldurmasın: kapalı durur, seçili gelmez. + Content the box never had (all of it on a fresh install) stays folded and unselected. */ + const eksik = i => i.kind === "zim" && !i.current && !i.id.startsWith("usb."); + const yeni = items.filter(i => !eksik(i)), yok = items.filter(eksik); + html = `` + html; // Varsayılan seçim toplamda sığanlarla sınırlı / pre-select only what fits together let room = disk.free - (disk.reserve || 0); - for (const i of items) { - const pick = i.fits && (i.size || 0) <= room; + if (yok.length) yeni.push({toggle: true}); + for (const i of [...yeni, ...yok]) { + if (i.toggle) { + const boyut = fmtSize(yok.reduce((t, x) => t + (x.size || 0), 0)); + html += ``; + continue; + } + const pick = !eksik(i) && i.fits && (i.size || 0) <= room; if (pick) room -= i.size || 0; - html += ` + html += ` @@ -364,7 +374,15 @@ } document.getElementById("upd").addEventListener("change", updTotal); -document.getElementById("upd").addEventListener("click", e => { if (e.target.id === "upd-go") startUpdate(); }); +document.getElementById("upd").addEventListener("click", e => { + if (e.target.id === "upd-go") startUpdate(); + if (e.target.id === "upd-more") { + e.preventDefault(); + const rows = document.querySelectorAll("#upd tr.eksik"), open = rows.length && rows[0].hidden; + rows.forEach(r => { r.hidden = !open; }); + e.target.textContent = e.target.textContent.replace(open ? "göster" : "gizle", open ? "gizle" : "göster"); + } +}); loadBooks().then(render); loadUpdates(); From 30090205c4ac94bc0f85759257f4c8a8aee0264d Mon Sep 17 00:00:00 2001 From: Firewall <149217425+Proxy34@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:05:22 +0300 Subject: [PATCH 10/11] =?UTF-8?q?Global=20wrong-password=20cap;=20USB=20ta?= =?UTF-8?q?kes=20only=20content.conf=20items=20/=20Toplam=20yanl=C4=B1?= =?UTF-8?q?=C5=9F=20=C5=9Fifre=20s=C4=B1n=C4=B1r=C4=B1;=20USB'den=20yaln?= =?UTF-8?q?=C4=B1zca=20content.conf=20i=C3=A7erikleri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++-- scripts/auto-update.sh | 6 +++++- update/update-api.py | 11 +++++++---- www/home/index.html | 1 + 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 93ce120..71be58b 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ The Pi's built-in Wi-Fi is dedicated to the hotspot. If the Pi needs internet te - The check runs at most every 6 hours, plus once a day for boxes that stay online. - Check now: `sudo ./scripts/auto-update.sh --check --force`. Update from the terminal: `sudo ./scripts/auto-update.sh --apply wikipedia_tr_all_maxi`. Logs: `journalctl -u has-update-check -u has-update-apply` -**From a USB drive, without internet.** Copy `.zim` files (with their original names, e.g. `wikipedia_tr_all_maxi_2026-09.zim`) to a USB drive, anywhere up to three folders deep, and plug it into the Pi. The drive is mounted read-only and the files that are missing or newer than what the box has show up in the same list, marked "(USB'den)". Before a copied file is used, the MD5 checksum stored inside every ZIM file is verified, so a damaged copy is never installed. The SSD that holds `/library` is never treated as a content drive, even when it is connected over USB. Use exFAT or NTFS for files over 4 GB. +**From a USB drive, without internet.** Copy `.zim` files (with their original names, e.g. `wikipedia_tr_all_maxi_2026-09.zim`) to a USB drive, anywhere up to three folders deep, and plug it into the Pi. The drive is mounted read-only and the files listed in `config/content.conf` that are missing or newer than what the box has show up in the same list, marked "(USB'den)". Before a copied file is used, the MD5 checksum stored inside every ZIM file is verified, so a damaged copy is never installed. The checksum does not prove who made the file, so only approve drives from a source you trust. The SSD that holds `/library` is never treated as a content drive, even when it is connected over USB. Use exFAT or NTFS for files over 4 GB. ### Repo layout @@ -214,7 +214,7 @@ Pi'nin dahili Wi-Fi kartı sadece hotspot için kullanılıyor. Pi'nin geçici o - Kontrol en fazla 6 saatte bir yapılır, sürekli bağlı cihazlarda ayrıca günde bir. - Hemen kontrol: `sudo ./scripts/auto-update.sh --check --force`. Terminalden güncelleme: `sudo ./scripts/auto-update.sh --apply wikipedia_tr_all_maxi`. Kayıtlar: `journalctl -u has-update-check -u has-update-apply` -**İnternetsiz, USB diskten.** `.zim` dosyalarını orijinal adlarıyla (ör. `wikipedia_tr_all_maxi_2026-09.zim`) bir USB diske, en fazla üç klasör derinliğe kopyala ve Pi'ye tak. Disk salt okunur bağlanır; kutuda olmayan ya da kutudakinden yeni olan dosyalar aynı listede "(USB'den)" etiketiyle çıkar. Kopyalanan dosya kullanılmadan önce her ZIM dosyasının içinde saklanan MD5 özeti doğrulanır, bozuk kopya asla kurulmaz. `/library`'yi tutan SSD, USB ile bağlı olsa bile içerik diski sayılmaz. 4 GB'tan büyük dosyalar için diski exFAT ya da NTFS biçimlendir. +**İnternetsiz, USB diskten.** `.zim` dosyalarını orijinal adlarıyla (ör. `wikipedia_tr_all_maxi_2026-09.zim`) bir USB diske, en fazla üç klasör derinliğe kopyala ve Pi'ye tak. Disk salt okunur bağlanır; `config/content.conf`'ta olup kutuda olmayan ya da kutudakinden yeni olan dosyalar aynı listede "(USB'den)" etiketiyle çıkar. Kopyalanan dosya kullanılmadan önce her ZIM dosyasının içinde saklanan MD5 özeti doğrulanır, bozuk kopya asla kurulmaz. Bu özet dosyayı kimin hazırladığını kanıtlamaz; yalnızca güvendiğin bir kaynaktan gelen diski onayla. `/library`'yi tutan SSD, USB ile bağlı olsa bile içerik diski sayılmaz. 4 GB'tan büyük dosyalar için diski exFAT ya da NTFS biçimlendir. ### Repo yapısı diff --git a/scripts/auto-update.sh b/scripts/auto-update.sh index 002908b..1821c48 100755 --- a/scripts/auto-update.sh +++ b/scripts/auto-update.sh @@ -142,12 +142,16 @@ usb_remove(){ } usb_scan(){ - local avail items="" sep="" seen=" " base prefix current src size fits + local avail items="" sep="" seen=" " base prefix current src size fits conf avail=$(df -B1 --output=avail "$DEST" | tail -1 | tr -dc '0-9') + # MD5 bozulmayı yakalar, değiştirilmiş dosyayı yakalamaz; USB'den yalnızca content.conf'taki içerikler alınır. + # The MD5 catches damage, not tampering; only content listed in content.conf is taken from a USB drive. + conf=" $(awk '$1 !~ /^#/ && NF >= 2 {printf "%s ", $2}' "$CONF")" # Ters sıralı: aynı ZIM'in birden çok sürümü varsa en yenisi önce gelir / reverse order: newest version first while read -r base; do [[ "$base" =~ ^([A-Za-z0-9._-]+)_[0-9]{4}-[0-9]{2}[a-z]?\.zim$ ]] || continue prefix=${BASH_REMATCH[1]} + case "$conf" in *" $prefix "*) ;; *) continue;; esac case "$seen" in *" $prefix "*) continue;; esac seen+="$prefix " current=$(cd "$DEST" && ls "$prefix"_[0-9][0-9][0-9][0-9]-[0-9][0-9]*.zim 2>/dev/null | tail -1) diff --git a/update/update-api.py b/update/update-api.py index 5dec369..33568d4 100644 --- a/update/update-api.py +++ b/update/update-api.py @@ -27,8 +27,9 @@ PORT = int(os.environ.get("PORT", "8097")) # iiab-admin şifresinin /etc/shadow'daki özeti; admin-hash.sh yazar. / iiab-admin's hash from /etc/shadow, written by admin-hash.sh. ADMIN = os.path.join(os.path.dirname(REQUEST), "admin") -# Aynı cihazdan 10 dakikada en fazla 5 yanlış şifre / at most 5 wrong passwords per device per 10 minutes -RATE_N, RATE_WINDOW = 5, 600 +# 10 dakikada cihaz başına 5, IP değiştirmeye karşı tüm cihazlar için toplam 20 yanlış şifre. +# Per 10 minutes: 5 wrong passwords per device, 20 across all devices so changing IP does not help. +RATE_N, RATE_ALL, RATE_WINDOW = 5, 20, 600 lock = threading.Lock() fails = defaultdict(list) @@ -96,12 +97,14 @@ def do_POST(self): return self._send(400, {"error": "Geçersiz istek / bad request"}) ip, now = self.headers.get("X-Real-IP") or self.client_address[0], time.time() with lock: - fails[ip] = [t for t in fails[ip] if now - t < RATE_WINDOW] - if len(fails[ip]) >= RATE_N: + for k in (ip, "*"): + fails[k] = [t for t in fails[k] if now - t < RATE_WINDOW] + if len(fails[ip]) >= RATE_N or len(fails["*"]) >= RATE_ALL: return self._send(429, {"error": "Çok fazla yanlış şifre, biraz bekleyin / too many wrong passwords, wait a bit"}) if not isinstance(pw, str) or not password_ok(pw): with lock: fails[ip].append(now) + fails["*"].append(now) return self._send(403, {"error": "Şifre yanlış / wrong password"}) if running(): return self._send(409, {"error": "Güncelleme zaten sürüyor / an update is already running"}) diff --git a/www/home/index.html b/www/home/index.html index 0017a97..42cc7b2 100644 --- a/www/home/index.html +++ b/www/home/index.html @@ -337,6 +337,7 @@ box.dataset.room = disk.free - (disk.reserve || 0); html += ``; From ba79e4cd4eaf661d6c68f765c8dd3c1bf634446a Mon Sep 17 00:00:00 2001 From: Firewall <149217425+Proxy34@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:16:54 +0300 Subject: [PATCH 11/11] =?UTF-8?q?Update=20password=20box:=20full=20width?= =?UTF-8?q?=20and=20no=20zoom=20on=20phones=20/=20G=C3=BCncelleme=20=C5=9F?= =?UTF-8?q?ifre=20kutusu:=20telefonda=20tam=20geni=C5=9Flik,=20yak=C4=B1nl?= =?UTF-8?q?a=C5=9Ft=C4=B1rma=20yok?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- www/home/index.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/www/home/index.html b/www/home/index.html index 109bd4a..b7f0a20 100644 --- a/www/home/index.html +++ b/www/home/index.html @@ -68,6 +68,8 @@ .warn{color:#B00000;font-weight:bold} .bar{height:8px;background:#fff;border:1px solid #7F9DB9;margin-top:4px;max-width:400px} .bar i{display:block;height:100%;background:linear-gradient(#FFA34F,#DD6900)} + /* 16 px altı yazıda iPhone kutuya dokununca sayfayı yakınlaştırır / iPhone zooms into inputs under 16 px */ + @media (max-width:640px){ #upd-pw{font-size:16px;padding:6px;width:100%;box-sizing:border-box;margin-bottom:6px} } #disk td{color:#B00000;font-weight:bold;font-size:12px} .copy{text-align:center;font-size:10px;color:#444;margin:12px 0 4px;line-height:1.6}
Güncelleme sürüyor
+ ${esc(itemName(c.id || "", items))} +
${esc(fmtSize(c.done))}${pct === null ? "" : ` / ${esc(fmtSize(c.size))} (%${pct})`} + ${s.queue && s.queue.length ? ` · sırada ${s.queue.length} içerik daha` : ""}
+ ${pct === null ? "" : `
`} +
Bu sırada kütüphaneyi kullanmaya devam edebilirsiniz.
Son güncelleme (${esc(fmtDate(s.finished))}): + ${r.length - bad.length} içerik güncellendi${bad.length ? `, ${bad.length} tanesi başarısız: ${esc(bad.join(", "))}` : "."}
Yeni sürüm varSürümBoyut
${esc(itemName(i.id, items))} + ${i.fits ? "" : `
Diskte yer yok. Eski sürüm kullanılmaya devam ediyor.
`}
${esc(fmtVer(i.current))} → ${esc(fmtVer(i.latest))}${esc(fmtSize(i.size))}
+ ${u.metered ? `
Pi şu anda kotalı bir bağlantıda (ör. telefondan internet paylaşımı). İndirme mobil veri harcar.
` : ""} + + · Son kontrol: ${esc(fmtDate(u.checked))}
Güncellemeler
Yeni sürüm varSürümBoyut
${esc(itemName(i.id, items))} - ${i.fits ? "" : `
Diskte yer yok. Eski sürüm kullanılmaya devam ediyor.
`}
${esc(fmtVer(i.current))} → ${esc(fmtVer(i.latest))} ${esc(fmtSize(i.size))}
${u.metered ? `
Pi şu anda kotalı bir bağlantıda (ör. telefondan internet paylaşımı). İndirme mobil veri harcar.
` : ""} @@ -330,7 +337,10 @@ const el = document.getElementById("upd-total"); if (!el) return; const sel = [...document.querySelectorAll("#upd input:checked")]; - el.textContent = sel.length ? `Toplam ${fmtSize(sel.reduce((t, c) => t + +c.dataset.size, 0))}` : ""; + const total = sel.reduce((t, c) => t + +c.dataset.size, 0), over = total > +document.getElementById("upd").dataset.room; + el.textContent = sel.length ? `Toplam ${fmtSize(total)}` + (over ? " · Seçilenlerin hepsi diske sığmıyor, birkaçını kaldırın." : "") : ""; + el.className = over ? "warn" : ""; + document.getElementById("upd-go").disabled = !sel.length || over; } async function startUpdate(){ From bd32ae5dd8c9003e6f1158c5893a6f101a0e51b8 Mon Sep 17 00:00:00 2001 From: Firewall <149217425+Proxy34@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:13:09 +0300 Subject: [PATCH 04/11] =?UTF-8?q?Homepage:=20match=20ZIM=20prefix=20up=20t?= =?UTF-8?q?o=20'=5F'=20/=20ZIM=20=C3=B6n=20ekini=20'=5F'=20s=C4=B1n=C4=B1r?= =?UTF-8?q?=C4=B1yla=20e=C5=9Fle=C5=9Ftir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- www/home/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/home/index.html b/www/home/index.html index 537b78c..65dd97b 100644 --- a/www/home/index.html +++ b/www/home/index.html @@ -213,7 +213,7 @@ if (i.zim) { if (books === null) { url = "/kiwix/"; status = "Kütüphaneden açın"; } else { - const b = books.filter(b => b.id.startsWith(i.zim)).sort((a,z) => a.id < z.id ? 1 : -1)[0]; + const b = books.filter(b => b.id.startsWith(i.zim + "_")).sort((a,z) => a.id < z.id ? 1 : -1)[0]; if (b) { used.add(b.id); url = b.href; From e7a4df1be968ad43df54e1e8f3361df7671b5101 Mon Sep 17 00:00:00 2001 From: Firewall <149217425+Proxy34@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:31:12 +0300 Subject: [PATCH 05/11] =?UTF-8?q?Update=20from=20a=20USB=20drive=20without?= =?UTF-8?q?=20internet=20/=20=C4=B0nternetsiz=20USB'den=20g=C3=BCncelleme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit USB disk takılınca salt okunur bağlanır; kutuda olmayan ya da daha yeni ZIM'ler ana sayfadaki güncelleme listesine '(USB'den)' etiketiyle eklenir. Kopya, ZIM içindeki MD5 ile doğrulanır. fstab'daki ya da zaten bağlı diskler (USB'li SSD) atlanır; liste /run altında tutulur. install-auto-update.sh artık API'yi yeniden başlatıyor. --- README.md | 8 ++- scripts/auto-update.sh | 118 ++++++++++++++++++++++++++++++--- scripts/install-auto-update.sh | 7 +- update/99-has-usb.rules | 2 + update/has-usb@.service | 10 +++ update/update-api.py | 8 ++- www/home/index.html | 18 ++--- 7 files changed, 149 insertions(+), 22 deletions(-) create mode 100644 update/99-has-usb.rules create mode 100644 update/has-usb@.service diff --git a/README.md b/README.md index 68ef464..7ce0bc1 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,8 @@ The Pi's built-in Wi-Fi is dedicated to the hotspot. If the Pi needs internet te - The check runs at most every 6 hours, plus once a day for boxes that stay online. - Check now: `sudo ./scripts/auto-update.sh --check --force`. Update from the terminal: `sudo ./scripts/auto-update.sh --apply wikipedia_tr_all_maxi`. Logs: `journalctl -u has-update-check -u has-update-apply` +**From a USB drive, without internet.** Copy `.zim` files (with their original names, e.g. `wikipedia_tr_all_maxi_2026-09.zim`) to a USB drive, anywhere up to three folders deep, and plug it into the Pi. The drive is mounted read-only and the files that are missing or newer than what the box has show up in the same list, marked "(USB'den)". Before a copied file is used, the MD5 checksum stored inside every ZIM file is verified, so a damaged copy is never installed. The SSD that holds `/library` is never treated as a content drive, even when it is connected over USB. Use exFAT or NTFS for files over 4 GB. + ### Repo layout ``` @@ -93,7 +95,7 @@ scripts/download-content.sh downloads/updates ZIM files from Kiwix scripts/install-homepage.sh installs the custom homepage scripts/auto-update.sh checks for and applies updates scripts/install-auto-update.sh installs the update services -update/ update services, NetworkManager hook, nginx config +update/ update services, NetworkManager hook, USB rule, nginx config config/iiab-overrides.yml Internet-in-a-Box settings config/content.conf list of content to download www/home/index.html homepage @@ -212,6 +214,8 @@ Pi'nin dahili Wi-Fi kartı sadece hotspot için kullanılıyor. Pi'nin geçici o - Kontrol en fazla 6 saatte bir yapılır, sürekli bağlı cihazlarda ayrıca günde bir. - Hemen kontrol: `sudo ./scripts/auto-update.sh --check --force`. Terminalden güncelleme: `sudo ./scripts/auto-update.sh --apply wikipedia_tr_all_maxi`. Kayıtlar: `journalctl -u has-update-check -u has-update-apply` +**İnternetsiz, USB diskten.** `.zim` dosyalarını orijinal adlarıyla (ör. `wikipedia_tr_all_maxi_2026-09.zim`) bir USB diske, en fazla üç klasör derinliğe kopyala ve Pi'ye tak. Disk salt okunur bağlanır; kutuda olmayan ya da kutudakinden yeni olan dosyalar aynı listede "(USB'den)" etiketiyle çıkar. Kopyalanan dosya kullanılmadan önce her ZIM dosyasının içinde saklanan MD5 özeti doğrulanır, bozuk kopya asla kurulmaz. `/library`'yi tutan SSD, USB ile bağlı olsa bile içerik diski sayılmaz. 4 GB'tan büyük dosyalar için diski exFAT ya da NTFS biçimlendir. + ### Repo yapısı ``` @@ -222,7 +226,7 @@ scripts/download-content.sh Kiwix'ten ZIM dosyalarını indirir/günceller scripts/install-homepage.sh özel ana sayfayı kurar scripts/auto-update.sh güncellemeleri kontrol eder ve uygular scripts/install-auto-update.sh güncelleme servislerini kurar -update/ güncelleme servisleri, NetworkManager kancası, nginx ayarı +update/ güncelleme servisleri, NetworkManager kancası, USB kuralı, nginx ayarı config/iiab-overrides.yml Internet-in-a-Box ayarları config/content.conf indirilecek içerik listesi www/home/index.html ana sayfa diff --git a/scripts/auto-update.sh b/scripts/auto-update.sh index 9f9ec19..6ae7a90 100755 --- a/scripts/auto-update.sh +++ b/scripts/auto-update.sh @@ -2,15 +2,18 @@ # Otomatik güncelleme. Kendiliğinden hiçbir şey indirmez: # --check internet varsa yeni sürümleri bulur, ana sayfanın okuduğu updates.json'a yazar # --apply ana sayfada onaylanan içerikleri indirir, ilerlemeyi update-status.json'a yazar +# --usb takılı USB disklerdeki daha yeni ZIM'leri bulur, usb.json'a yazar (internet gerekmez) # Automatic updates. Never downloads anything on its own: # --check if online, finds newer versions and writes them to updates.json (read by the homepage) # --apply downloads the items approved on the homepage, progress goes to update-status.json +# --usb finds newer ZIMs on plugged-in USB drives and writes them to usb.json (no internet needed) # İndirmeyi download-content.sh ve download-map.sh yapar (SHA-256, yer kontrolü, eski sürümü silme). # The actual downloads are done by download-content.sh and download-map.sh. # # Kullanım / usage: # sudo ./scripts/auto-update.sh --check [--force] --force olmadan 6 saatte birden sık bakmaz / at most every 6 h without --force # sudo ./scripts/auto-update.sh --apply [id ...] id yoksa ana sayfanın istek dosyasını okur / without ids reads the homepage request +# sudo ./scripts/auto-update.sh --usb [--usb-add|--usb-remove sdX1] has-usb@.service çağırır / called by has-usb@.service # Ortam / environment: WEB=/library/www/html DEST=/library/zims/content RESERVE_GB=5 CHECK_HOURS=6 set -uo pipefail @@ -26,19 +29,24 @@ LOCK="${LOCK:-/run/lock/has-update.lock}" UPDATES="$WEB/home/updates.json" STATUS="$WEB/home/update-status.json" MAP_META="$WEB/harita/data/map.json" +# usb.json /run altında durur, yeniden başlatınca eski liste kalmaz / lives in /run so a reboot clears a stale list +USB_DIR="${USB_DIR:-/run/has-usb}" +USB_JSON="$USB_DIR/usb.json" +USB_ROOTS="${USB_ROOTS:-$USB_DIR /media}" BUILDS_URL=https://build-metadata.protomaps.dev/builds.json GB=$((1024 * 1024 * 1024)) -mode=""; FORCE=0; ids=() +mode=""; FORCE=0; ids=(); usbact=""; usbdev="" for a in "$@"; do case "$a" in - --check|--apply) mode=$a;; + --check|--apply|--usb) mode=$a;; + --usb-add|--usb-remove) mode=--usb; usbact=$a;; --force) FORCE=1;; -*) echo "bilinmeyen seçenek / unknown option: $a" >&2; exit 2;; - *) ids+=("$a");; + *) if [ -n "$usbact" ]; then usbdev=$a; else ids+=("$a"); fi;; esac done -[ -n "$mode" ] || { echo "kullanım / usage: $0 --check [--force] | --apply [id ...]" >&2; exit 2; } +[ -n "$mode" ] || { echo "kullanım / usage: $0 --check [--force] | --apply [id ...] | --usb" >&2; exit 2; } [ "$EUID" -eq 0 ] || { echo "sudo ile çalıştır / run with sudo" >&2; exit 1; } # JSON'a yalnızca bu karakterlerden oluşan değerler yazılır, kaçış gerekmez. @@ -105,6 +113,90 @@ EOF echo "OK $(grep -c '"id"' "$UPDATES") yeni sürüm / newer versions -> $UPDATES" } +# USB diski salt okunur bağlar. fstab'daki ya da zaten bağlı diskler (ör. USB'li SSD) atlanır. +# Mounts a USB drive read-only. Drives in fstab or already mounted (e.g. a USB SSD) are skipped. +usb_add(){ + local dev="$1" uuid + safe "$dev" && [ -b "/dev/$dev" ] || { echo "!! geçersiz aygıt / invalid device: $dev" >&2; return 1; } + if findmnt -rn -S "/dev/$dev" >/dev/null; then echo "== /dev/$dev zaten bağlı, atlandı / already mounted, skipped"; return 0; fi + uuid=$(blkid -s UUID -o value "/dev/$dev") + if [ -n "$uuid" ] && grep -q "UUID=$uuid" /etc/fstab; then echo "== /dev/$dev fstab'da, atlandı / in fstab, skipped"; return 0; fi + mkdir -p "$USB_DIR/$dev" + mount -o ro,nosuid,nodev,noexec "/dev/$dev" "$USB_DIR/$dev" || { rmdir "$USB_DIR/$dev"; return 1; } + echo "OK /dev/$dev -> $USB_DIR/$dev" +} + +usb_remove(){ + safe "$1" || return 1 + if mountpoint -q "$USB_DIR/$1"; then umount -l "$USB_DIR/$1"; fi + rmdir "$USB_DIR/$1" 2>/dev/null || true +} + +usb_scan(){ + local avail items="" sep="" seen=" " base prefix current src size fits + avail=$(df -B1 --output=avail "$DEST" | tail -1 | tr -dc '0-9') + # Ters sıralı: aynı ZIM'in birden çok sürümü varsa en yenisi önce gelir / reverse order: newest version first + while read -r base; do + [[ "$base" =~ ^([A-Za-z0-9._-]+)_[0-9]{4}-[0-9]{2}[a-z]?\.zim$ ]] || continue + prefix=${BASH_REMATCH[1]} + case "$seen" in *" $prefix "*) continue;; esac + seen+="$prefix " + current=$(cd "$DEST" && ls "$prefix"_[0-9][0-9][0-9][0-9]-[0-9][0-9]*.zim 2>/dev/null | tail -1) + [ -n "$current" ] && [[ ! "$base" > "$current" ]] && continue + src=$(find $USB_ROOTS -maxdepth 5 -type f -name "$base" -print -quit 2>/dev/null) + size=$(stat -c %s "$src") + fits=true; [ "$avail" -lt $((size + RESERVE_GB * GB)) ] && fits=false + items+="$sep"$'\n'" {\"id\":\"usb.$prefix\",\"kind\":\"usb\",\"file\":\"$base\",\"current\":\"$current\",\"latest\":\"$base\",\"size\":$size,\"fits\":$fits}" + sep="," + done < <(find $USB_ROOTS -maxdepth 5 -type f -name '*.zim' -printf '%f\n' 2>/dev/null | sort -ru) + mkdir -p "$USB_DIR" + write_json "$USB_JSON" < $USB_JSON" +} + +# ZIM dosyasının son 16 baytı, öncesinin MD5 özetidir (openZIM biçimi; yeri başlıkta checksumPos). +# The last 16 bytes of a ZIM file are the MD5 of everything before them (openZIM format, checksumPos in the header). +zim_ok(){ + python3 - "$1" <<'EOF' +import hashlib, struct, sys +with open(sys.argv[1], "rb") as f: + head = f.read(80) + if len(head) < 80 or head[:4] != b"ZIM\x04": + sys.exit(1) + left = struct.unpack_from("/dev/null) + [ -n "$src" ] || { echo "!! $file USB'de bulunamadı / not found on the USB drive"; return 1; } + avail=$(df -B1 --output=avail "$DEST" | tail -1 | tr -dc '0-9') + if [ "$avail" -lt $(( $(stat -c %s "$src") + RESERVE_GB * GB )) ]; then + echo "!! $file için yer yok / not enough space"; return 1 + fi + cp "$src" "$DEST/$file.part" || { echo "!! kopyalanamadı / copy failed"; rm -f "$DEST/$file.part"; return 1; } + echo " ZIM sağlaması doğrulanıyor / verifying ZIM checksum" + zim_ok "$DEST/$file.part" || { echo "!! $file bozuk, silindi / corrupt, deleted"; rm -f "$DEST/$file.part"; return 1; } + mv "$DEST/$file.part" "$DEST/$file" + for old in "$DEST/$prefix"_[0-9][0-9][0-9][0-9]-[0-9][0-9]*.zim; do + [ "$old" != "$DEST/$file" ] && [ -f "$old" ] && { echo "-- eski sürüm siliniyor / removing $old"; rm -f "$old"; } + done + iiab-make-kiwix-lib +} + results="" status(){ # durum [ek alanlar] / state [extra fields] write_json "$STATUS" <&2; return 1; } local i j id line queue failed=0 ok for i in "${!ids[@]}"; do id=${ids[$i]} safe "$id" || { echo "!! geçersiz ad / invalid id: $id" >&2; failed=1; continue; } queue=""; for j in "${ids[@]:i+1}"; do safe "$j" && queue+="${queue:+,}\"$j\""; done - line=$(grep -F "{\"id\":\"$id\"," "$UPDATES") + line=$(grep -shF "{\"id\":\"$id\"," "$UPDATES" "$USB_JSON" | head -1) if [ -z "$line" ] || [[ "$line" != *'"fits":true'* ]]; then echo "!! $id güncelleme listesinde yok ya da yer yok / not in the update list or no space" >&2; ok=false else @@ -164,13 +258,21 @@ apply(){ results+="${results:+,}\"$id\":$ok" done if [ "$failed" = 0 ]; then status "done" "\"finished\":\"$(date -Is)\""; else status error "\"finished\":\"$(date -Is)\""; fi + usb_scan FORCE=1 check return $failed } mkdir -p "$(dirname "$LOCK")" exec 9>"$LOCK" -if [ "$mode" = --check ]; then +if [ "$mode" = --usb ]; then + case "$usbact" in + --usb-add) usb_add "$usbdev" || exit 1;; + --usb-remove) usb_remove "$usbdev";; + esac + flock -n 9 || { echo "== güncelleme sürüyor, sonunda taranacak / update running, rescans when done"; exit 0; } + usb_scan +elif [ "$mode" = --check ]; then flock -n 9 || { echo "== başka bir güncelleme çalışıyor / another update is running"; exit 0; } check else diff --git a/scripts/install-auto-update.sh b/scripts/install-auto-update.sh index 60b39b5..ae96321 100755 --- a/scripts/install-auto-update.sh +++ b/scripts/install-auto-update.sh @@ -11,7 +11,7 @@ REPO="$(cd "$(dirname "$0")/.." && pwd)" SRC="$REPO/update" APP=/opt/has-update NGINX_CONF=/etc/nginx/conf.d/has-update-nginx.conf -UNITS="has-update-check.service has-update-check.timer has-update-apply.service has-update-apply.path has-update-api.service" +UNITS="has-update-check.service has-update-check.timer has-update-apply.service has-update-apply.path has-update-api.service has-usb@.service" die(){ echo "HATA / ERROR: $*" >&2; exit 1; } [ "$EUID" -eq 0 ] || die "sudo ile çalıştır / run with sudo: sudo ./scripts/install-auto-update.sh" @@ -32,8 +32,13 @@ else echo "Uyarı / warning: NetworkManager yok, kontrol yalnızca günde bir yapılır / no NetworkManager, checks run daily only" fi +install -m 644 "$SRC/99-has-usb.rules" /etc/udev/rules.d/99-has-usb.rules +udevadm control --reload +ln -sfn /run/has-usb/usb.json /library/www/html/home/usb.json + systemctl daemon-reload systemctl enable --now has-update-api.service has-update-apply.path has-update-check.timer >/dev/null +systemctl restart has-update-api.service install -m 644 "$SRC/update-nginx.conf" "$NGINX_CONF" if nginx -t 2>/dev/null; then diff --git a/update/99-has-usb.rules b/update/99-has-usb.rules new file mode 100644 index 0000000..031a715 --- /dev/null +++ b/update/99-has-usb.rules @@ -0,0 +1,2 @@ +# USB disk takılınca içerik taraması / scan a USB drive for content when it is plugged in +ACTION=="add", SUBSYSTEM=="block", ENV{ID_BUS}=="usb", ENV{ID_FS_USAGE}=="filesystem", TAG+="systemd", ENV{SYSTEMD_WANTS}+="has-usb@%k.service" diff --git a/update/has-usb@.service b/update/has-usb@.service new file mode 100644 index 0000000..2fcc292 --- /dev/null +++ b/update/has-usb@.service @@ -0,0 +1,10 @@ +[Unit] +Description=HAS_PrepperDisk: USB diskindeki içerikleri tara / scan USB drive %I for content +BindsTo=dev-%i.device +After=dev-%i.device local-fs.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=@REPO@/scripts/auto-update.sh --usb-add %I +ExecStop=@REPO@/scripts/auto-update.sh --usb-remove %I diff --git a/update/update-api.py b/update/update-api.py index 976cf99..559a8be 100644 --- a/update/update-api.py +++ b/update/update-api.py @@ -2,11 +2,11 @@ """HAS_PrepperDisk güncelleme butonu: POST http://box.lan/guncelleme/ {"ids": [...]} Ana sayfadaki "Güncelle" butonu buraya istek atar. Servis yetkisiz bir kullanıcıyla çalışır ve -hiçbir şey indirmez: seçimi updates.json'a göre denetleyip istek dosyasına yazar, indirmeyi +hiçbir şey indirmez: seçimi updates.json ve usb.json'a göre denetleyip istek dosyasına yazar, indirmeyi has-update-apply.path (root) başlatır. The homepage "Güncelle" button posts here. The service runs unprivileged and downloads nothing: -it checks the selection against updates.json and writes the request file; has-update-apply.path +it checks the selection against updates.json and usb.json and writes the request file; has-update-apply.path (root) starts the download. """ import json @@ -16,6 +16,7 @@ WEB = os.environ.get("WEB", "/library/www/html") UPDATES = os.path.join(WEB, "home", "updates.json") +USB = os.path.join(WEB, "home", "usb.json") STATUS = os.path.join(WEB, "home", "update-status.json") REQUEST = os.environ.get("REQUEST", "/run/has-update/request") PORT = int(os.environ.get("PORT", "8097")) @@ -62,7 +63,8 @@ def do_POST(self): return self._send(400, {"error": "Geçersiz istek / bad request"}) if running(): return self._send(409, {"error": "Güncelleme zaten sürüyor / an update is already running"}) - allowed = {i.get("id") for i in read_json(UPDATES).get("items", []) if i.get("fits")} + items = read_json(UPDATES).get("items", []) + read_json(USB).get("items", []) + allowed = {i.get("id") for i in items if i.get("fits")} if not isinstance(ids, list) or not ids or not all(isinstance(i, str) and i in allowed for i in ids): return self._send(400, {"error": "Seçim geçersiz ya da diskte yer yok / invalid selection or no space"}) tmp = REQUEST + ".tmp" diff --git a/www/home/index.html b/www/home/index.html index 65dd97b..ebac9b8 100644 --- a/www/home/index.html +++ b/www/home/index.html @@ -282,13 +282,15 @@ function itemName(id, items){ const i = (items || []).find(x => x.id === id) || {}; if (i.kind === "map") return `Çevrimdışı Harita (${i.name})`; - const c = CATS.flatMap(c => c.items).find(x => x.zim && id.startsWith(x.zim)); - return c ? c.name : id; + const usb = id.startsWith("usb."), key = usb ? id.slice(4) : id; + const c = CATS.flatMap(c => c.items).find(x => x.zim && key.startsWith(x.zim)); + return (c ? c.name : key) + (usb ? " (USB'den)" : ""); } async function loadUpdates(){ - const [u, s] = await Promise.all([getJSON("/home/updates.json"), getJSON("/home/update-status.json")]); - const box = document.getElementById("upd"), items = (u && u.items) || []; + const [u, usb, s] = await Promise.all([getJSON("/home/updates.json"), getJSON("/home/usb.json"), getJSON("/home/update-status.json")]); + const box = document.getElementById("upd"), items = [...((usb && usb.items) || []), ...((u && u.items) || [])]; + const disk = usb || u; let html = ""; if (s && s.state === "running" && Date.now() - new Date(s.updated) < 120000) { const c = s.current || {}, pct = c.size ? Math.min(100, Math.floor(c.done * 100 / c.size)) : null; @@ -310,7 +312,7 @@ if (items.length) { html = `
Yeni sürüm varSürümBoyut
${esc(fmtVer(i.current))} → ${esc(fmtVer(i.latest))} ${esc(fmtSize(i.size))}
- ${u.metered ? `
Pi şu anda kotalı bir bağlantıda (ör. telefondan internet paylaşımı). İndirme mobil veri harcar.
` : ""} + ${u && u.metered && u.items.length ? `
Pi şu anda kotalı bir bağlantıda (ör. telefondan internet paylaşımı). İndirme mobil veri harcar.
` : ""} - · Son kontrol: ${esc(fmtDate(u.checked))}
Güncellemeler
${u && u.metered && u.items.length ? `
Pi şu anda kotalı bir bağlantıda (ör. telefondan internet paylaşımı). İndirme mobil veri harcar.
` : ""} + ${u ? ` · Son kontrol: ${esc(fmtDate(u.checked))}` : ""}
${u && u.metered && u.items.length ? `
Pi şu anda kotalı bir bağlantıda (ör. telefondan internet paylaşımı). İndirme mobil veri harcar.
` : ""} - + ${u ? ` · Son kontrol: ${esc(fmtDate(u.checked))}` : ""}
Yeni sürüm varSürümBoyut
${yeni.length ? "Yeni sürüm var" : "Kutuda olmayan içerikler"}SürümBoyut
Kutuda olmayan ${yok.length} içerik (toplam ${esc(boyut)}): göster
${esc(itemName(i.id, items))} ${i.fits ? "" : `
Diskte yer yok.${i.current ? " Eski sürüm kullanılmaya devam ediyor." : ""}
`}
${u && u.metered && u.items.length ? `
Pi şu anda kotalı bir bağlantıda (ör. telefondan internet paylaşımı). İndirme mobil veri harcar.
` : ""} + ${usb && usb.items.length ? `
USB'den gelen içerikleri yalnızca güvendiğiniz bir kaynaktan geldiyse seçin. Dosyanın bozuk olmadığı doğrulanır, ama kimin hazırladığı doğrulanamaz.
` : ""} ${u ? ` · Son kontrol: ${esc(fmtDate(u.checked))}` : ""}