Compare commits
No commits in common. "2fbafd5c77600c5055b0fc19eda413c95e0b3ab5" and "8e281fa4a64d519fa8d2ddd6b6dcd976f6da0810" have entirely different histories.
2fbafd5c77
...
8e281fa4a6
22 changed files with 18 additions and 658 deletions
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -7,18 +7,6 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Self-update starts newly added timers immediately and links units
|
||||
through `current`.** `_link_new_units()` enabled a timer a release
|
||||
introduced but never started it, so after the 26.19 → 26.20 update the
|
||||
bench sat with `furtka-welcome.timer` `inactive (dead)` — and the console
|
||||
banner without its IP line — until the next reboot. It also linked the
|
||||
unit from `/opt/furtka/versions/<ver>/…` instead of `/opt/furtka/current/…`,
|
||||
so the link would have stopped following later updates and rollbacks
|
||||
(installer-created links use `current`). Now: link via `current`, enable,
|
||||
start.
|
||||
|
||||
## [26.20-alpha] - 2026-08-25
|
||||
|
||||
First release shaped by a real-hardware install (old laptop mainboard, BIOS boot,
|
||||
|
|
|
|||
|
|
@ -102,7 +102,6 @@ None of these nail the "your dad can set this up" experience. The installer wiza
|
|||
- [x] Installer webapp prototype — device reader + form → JSON (Robert)
|
||||
- [x] Arch running on Proxmox, custom image builds in progress (Robert)
|
||||
- [x] Competitor analysis — see [docs/competitors.md](docs/competitors.md)
|
||||
- [x] First bare-metal install (old laptop board, BIOS, SSD with a previous Proxmox VE) — 4 bugs found and shipped in 26.20-alpha, report in [docs/hardware-test-2026-08.md](docs/hardware-test-2026-08.md)
|
||||
- [x] Wizard flow spec — see [docs/wizard-flow.md](docs/wizard-flow.md)
|
||||
- [x] Release process + CI — CalVer tags, conventional commits, Forgejo Actions (ruff, pytest, JSON, link checks), `26.0-alpha` tagged
|
||||
- [x] Forgejo runner live on Proxmox VM (`forge-runner-01`, Ubuntu 24.04) — docker-outside-of-docker with host-mode jobs for ISO builds, setup captured in [docs/runner-setup.md](docs/runner-setup.md) + [ops/forgejo-runner/](ops/forgejo-runner/)
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
# Hardware test report — "junk bench", 2026-08-24/26
|
||||
|
||||
First end-to-end run of Furtka on real hardware that was not a Proxmox VM
|
||||
and not the Medion box: a bare old-laptop mainboard lying on the desk, a
|
||||
1 TB Crucial BX SATA SSD that had previously lived in a Proxmox VE host, a
|
||||
USB hub for keyboard + boot stick, LAN cable, HDMI monitor. Tester: Daniel.
|
||||
Release under test: **26.19-alpha**, then self-updated to **26.20-alpha**.
|
||||
|
||||
Why it mattered: every bug below is invisible on the smoke VM. A VM has a
|
||||
fresh empty disk, a fast virtual NIC, UEFI firmware and no USB stick to pull.
|
||||
|
||||
## Timeline
|
||||
|
||||
| When | What happened | Outcome |
|
||||
|---|---|---|
|
||||
| 08-24 | ISO flashed with Fedora Media Writer, bench booted, browser opened `proski.local:5000` | "Server Not Found" — **typo** (`proski` vs `proksi`). Bench powered off. |
|
||||
| 08-25 | `proksi.local:5000` — wizard came up (BIOS/syslinux boot path, stock Arch splash) | OK |
|
||||
| 08-25 13:07 | Install run 1 | **Failed at 12 %**: `Partition(s) 2, 3 on /dev/sda have been written, but we have been unable to inform the kernel of the change … in use` |
|
||||
| 08-25 | Root shell over SSH (`passwd` + `ssh-copy-id` on the live ISO): SSD still carried a Proxmox VE `pve` volume group (swap, root, thin pool); udev had activated it, six device-mapper nodes sat on `sda3` | Cleared by hand: `dmsetup remove` ×6, `wipefs -a /dev/sda`, `blockdev --rereadpt` |
|
||||
| 08-25 13:16 | Install run 2 | `Installation completed without any errors`; BIOS detected → `Grub`, MBR table, `sda1` 1 G vfat `/boot`, `sda2` ext4 `/` |
|
||||
| 08-25 | "Reboot now" clicked | Box **booted the USB stick again** (BIOS order). Stick pulled, rebooted → `furtka.local` = 192.168.178.142, Caddy answering |
|
||||
| 08-25 16:22 | First boot of the installed system | `furtka-catalog-sync.service` failed: `Network is unreachable`. `network-online.target` had fired on the IPv6 SLAAC address ~1 s before DHCPv4 landed. State file stuck on `checking`, apps page empty; next timer run would have been 18:26 |
|
||||
| 08-25 ~17:00 | Catalog synced via the "Sync apps catalog" button | Catalog 26.13-alpha, 7 apps listed |
|
||||
| 08-25 17:47 | 26.20-alpha released with the fixes below | Box reports `Update available: 26.19-alpha → 26.20-alpha` |
|
||||
| 08-26 09:34 | Self-update to 26.20-alpha from `/settings` | `current → 26.20-alpha`, no failed units. **New finding:** `furtka-welcome.timer` linked + enabled by the updater but `inactive (dead)` — `enable` alone only arms it for the next boot |
|
||||
|
||||
## Findings and fixes
|
||||
|
||||
| # | Finding | Root cause | Fix | Shipped in | Validated on hardware |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | Console banner showed only `proksi.local` / `furtka.local`, never the IP fallback | Banner script was a oneshot after `network-online.target` and dropped the IP line when no address existed yet. On the live ISO releng's root autologin on tty1 also made `agetty --reload` a no-op | `furtka-issue.timer` (ISO) / `furtka-welcome.timer` (installed) re-run the script every 5 s; fallback line is always present ("no IP address yet — check the cable / DHCP" until one arrives); ISO no longer auto-logs root in on tty1 | 26.20-alpha (`0e05bef`) | Installed side: pending reboot or next update. ISO side: pending reinstall from the 26.20 ISO |
|
||||
| 2 | Install failed at partitioning on a disk with a previous OS | Live ISO ships lvm2/mdadm/dmraid/cryptsetup; udev auto-activates whatever the disk carries; parted cannot re-read the partition table while dm nodes hold the partitions | `webinstaller/diskprep.py`: `release_disk()` runs before archinstall — swapoff/umount leaves-first, `dmsetup remove` / `mdadm --stop`, `wipefs -a`, `blockdev --rereadpt`; steps logged at the top of the install log; progress page names this failure explicitly | 26.20-alpha (`5ddda53`) | Pending reinstall from the 26.20 ISO (the disk now carries Furtka itself, which exercises the same path minus LVM) |
|
||||
| 3 | "Remove the USB stick" was easy to miss — the box booted the installer again | Hint lived on the *rebooting* page and in a `confirm()` popup, i.e. after the click, with the machine restarting 3 s later | Done state shows a two-step list (pull the stick → restart) above the button; button reads "USB stick is out — restart now" | 26.20-alpha (`60404ae`) | Pending reinstall |
|
||||
| 4 | No apps after first boot | Boot-time catalog sync raced DHCPv4 (IPv6 made `network-online` fire early), no retry, timer jitter pushed the next attempt 2 h out, state file stuck on `checking` | `furtka-catalog-sync.service` retries on failure (20 s, up to 8× in 15 min); failures recorded as stage `error`; apps page shows "last sync failed: …" | 26.20-alpha (`32ba522`) | Retry config confirmed live on the box after the update; a cold boot with the new unit is still pending |
|
||||
| 5 | New timer dead after self-update | `_link_new_units()` linked + enabled but never started the timer; also linked from the versioned dir instead of `current` | Link via `current`, enable, start | Unreleased (this branch) | — |
|
||||
|
||||
Not a bug, but worth knowing: on this board the BIOS boots the USB stick
|
||||
before the SSD, so every reinstall needs the stick pulled at the reboot
|
||||
prompt. The stock Arch splash on the BIOS/syslinux boot path is unbranded
|
||||
(only menu labels are) — cosmetic.
|
||||
|
||||
## What worked first time
|
||||
|
||||
- Fedora Media Writer → hybrid ISO boots on BIOS firmware.
|
||||
- mDNS: `proksi.local` (live ISO) and `furtka.local` (installed) resolved
|
||||
from a Fedora client once spelled correctly.
|
||||
- BIOS detection → `Grub` + MBR layout; the installed system booted from
|
||||
the SSD without intervention.
|
||||
- Login, `/settings`, catalog sync via the UI button, `furtka update --check`,
|
||||
self-update 26.19 → 26.20 with symlink flip and clean unit state.
|
||||
- Time sync, Docker 29.7.2, Caddy on :80, sshd with key auth for the wizard
|
||||
user (`wheel` + `docker` groups as configured).
|
||||
|
||||
## Still open from this run
|
||||
|
||||
- Reinstall from the 26.20-alpha ISO on the bench to validate findings 1–3
|
||||
on the ISO side (the installed side of #1 validates on the next reboot).
|
||||
- Cold boot with the retrying catalog-sync unit (#4) on a dual-stack LAN.
|
||||
- Rebrand the syslinux boot splash.
|
||||
- `smoke-latest.yml` is `workflow_dispatch` only and has not run since
|
||||
April; the smoke VM would not have caught any of the above anyway, but it
|
||||
is currently not running at all.
|
||||
|
||||
## Reproducing the bench conditions in a VM
|
||||
|
||||
Most of this can be provoked on Proxmox if you want a regression check
|
||||
without the hardware:
|
||||
|
||||
- Finding 2: attach a disk that already carries an LVM install (e.g. a
|
||||
spare Proxmox VE or Ubuntu LVM disk image) before booting the ISO.
|
||||
- Finding 3: leave the ISO attached as CD-ROM with boot order CD-first.
|
||||
- Finding 4: give the VM's bridge IPv6 RA before DHCPv4 — harder; the
|
||||
retry logic is unit-tested instead.
|
||||
- Finding 1: use SeaBIOS instead of OVMF (the README notes SeaBIOS fails on
|
||||
`ldlinux.c32` from this ISO, so this one really needs hardware).
|
||||
|
|
@ -226,17 +226,12 @@ def _link_new_units(unit_dir: Path) -> list[str]:
|
|||
"""`systemctl link` any unit file in unit_dir that isn't already symlinked
|
||||
into /etc/systemd/system/. Returns the list of newly-linked unit names.
|
||||
|
||||
Newly-linked `.timer` units are additionally `systemctl enable`d *and
|
||||
started* so that a self-update introducing a timer (e.g. 26.5 → 26.6
|
||||
adding furtka-catalog-sync.timer, 26.19 → 26.20 adding
|
||||
furtka-welcome.timer) activates it right away — the installer's enable
|
||||
list only applies to fresh installs, and `enable` alone only takes
|
||||
effect at the next boot (the bench sat on a dead welcome timer after the
|
||||
26.20 update).
|
||||
|
||||
Units are linked through the `/opt/furtka/current` symlink, not the
|
||||
versioned directory that `unit_dir` scans, so the link keeps following
|
||||
later updates and rollbacks the same way the installer-created links do.
|
||||
Newly-linked `.timer` units are additionally `systemctl enable`d so that
|
||||
a self-update introducing a timer (e.g. 26.5 → 26.6 adding
|
||||
furtka-catalog-sync.timer) activates it automatically — the installer's
|
||||
enable list only applies to fresh installs. A linked-but-disabled timer
|
||||
never fires on its own, so without this step catalog sync would never
|
||||
happen on upgraded boxes.
|
||||
"""
|
||||
if not unit_dir.is_dir():
|
||||
return []
|
||||
|
|
@ -247,10 +242,9 @@ def _link_new_units(unit_dir: Path) -> list[str]:
|
|||
target = _SYSTEMD_DIR / unit_file.name
|
||||
if target.exists() or target.is_symlink():
|
||||
continue
|
||||
_run(["systemctl", "link", str(current_symlink() / "assets" / "systemd" / unit_file.name)])
|
||||
_run(["systemctl", "link", str(unit_file)])
|
||||
if unit_file.suffix == ".timer":
|
||||
_run(["systemctl", "enable", unit_file.name])
|
||||
_run(["systemctl", "start", unit_file.name])
|
||||
linked.append(unit_file.name)
|
||||
return linked
|
||||
|
||||
|
|
|
|||
|
|
@ -51,27 +51,6 @@ mDNS is wired: `avahi-daemon` + `nss-mdns` come from `packages.extra`, the live
|
|||
5. Find its IP in Proxmox's VM summary (or your router's DHCP table)
|
||||
6. Open `http://<vm-ip>:5000` — the existing 3-screen wizard should be there
|
||||
|
||||
## Real hardware (what differs from the VM flow)
|
||||
|
||||
Learned on the first bare-metal run, see [docs/hardware-test-2026-08.md](../docs/hardware-test-2026-08.md):
|
||||
|
||||
- **BIOS boxes work.** The hybrid ISO boots via syslinux on legacy firmware
|
||||
(you'll see the stock Arch splash — only the menu labels are branded), and
|
||||
the installer detects the missing `/sys/firmware/efi` and picks GRUB + MBR.
|
||||
- **Pull the USB stick before "restart now".** Most BIOSes boot USB before
|
||||
the disk; the done page says so above the button since 26.20-alpha.
|
||||
- **Disks with a previous OS are fine since 26.20-alpha.** The live ISO
|
||||
auto-activates old LVM/RAID/swap; `webinstaller/diskprep.py` releases the
|
||||
disk before archinstall partitions it. On 26.19 and earlier you'd hit
|
||||
`unable to inform the kernel of the change … in use` at 12 % — clear it
|
||||
from the tty1 root shell with `dmsetup remove <node>` for every dm node
|
||||
shown by `lsblk`, then `wipefs -a /dev/sdX` and retry.
|
||||
- **Slow NICs.** The console banner's IP fallback line is timer-driven
|
||||
since 26.20-alpha; on 26.19 it only appeared if DHCP beat getty.
|
||||
- **Shell on the live ISO**: since 26.20-alpha tty1 stays at the login
|
||||
prompt (banner refreshes live); log in as `root`, no password. sshd runs;
|
||||
set a root password with `passwd` to reach it over the network.
|
||||
|
||||
## What you see after install + reboot
|
||||
|
||||
Once `archinstall` finishes and you click **Reboot now**, the VM comes up into the installed system. No more port `:5000` — the wizard ISO is gone. Instead:
|
||||
|
|
|
|||
|
|
@ -29,16 +29,6 @@ server {
|
|||
try_files $uri $uri/ $uri.html =404;
|
||||
}
|
||||
|
||||
# Poll counter (ops/poll/pollsvc.py) — cookie-free, rate-limited.
|
||||
location /api/poll/ {
|
||||
limit_req zone=poll burst=10 nodelay;
|
||||
proxy_pass http://127.0.0.1:8090/;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header User-Agent $http_user_agent;
|
||||
proxy_read_timeout 5s;
|
||||
}
|
||||
|
||||
location = /favicon.svg {
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
# Rate limit for the poll API (http context; included from conf.d).
|
||||
limit_req_zone $binary_remote_addr zone=poll:1m rate=30r/m;
|
||||
|
|
@ -16,7 +16,6 @@ install -d -o "$OWNER" -g "$OWNER" -m 0755 "$WEBROOT"
|
|||
install -d -o "$OWNER" -g "$OWNER" -m 0755 "$SRCROOT"
|
||||
|
||||
cp "$(dirname "$0")/furtka.org.conf" "$SITE_CONF"
|
||||
cp "$(dirname "$0")/poll-ratelimit.conf" /etc/nginx/conf.d/poll-ratelimit.conf
|
||||
ln -sfn "$SITE_CONF" "$SITE_LINK"
|
||||
|
||||
# Drop the Ubuntu default site so it doesn't shadow us on :80.
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
[Unit]
|
||||
Description=furtka.org poll counter
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/python3 /opt/furtka-poll/pollsvc.py
|
||||
DynamicUser=yes
|
||||
StateDirectory=furtka-poll
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Tiny cookie-free poll counter for furtka.org.
|
||||
|
||||
Runs behind nginx on 127.0.0.1:8090. One SQLite file, no dependencies
|
||||
beyond the standard library.
|
||||
|
||||
GET /<poll> -> {"poll": "logo", "counts": {"m": 3, "r": 5}, "total": 8}
|
||||
POST /<poll> -> body {"choice": "m"}; answers like GET plus "yours"
|
||||
|
||||
A device is identified by a salted SHA-256 of client IP + User-Agent, so
|
||||
nobody needs a cookie and the raw address is never stored. Voting again
|
||||
from the same device changes the vote instead of adding one.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
POLLS = {
|
||||
"logo": ("m", "r"),
|
||||
}
|
||||
STATE_DIR = os.environ.get("STATE_DIRECTORY", "/var/lib/furtka-poll")
|
||||
DB_PATH = os.path.join(STATE_DIR, "votes.db")
|
||||
SALT_PATH = os.path.join(STATE_DIR, "salt")
|
||||
LISTEN = os.environ.get("POLL_LISTEN", "127.0.0.1")
|
||||
PORT = int(os.environ.get("POLL_PORT", "8090"))
|
||||
MAX_BODY = 512
|
||||
|
||||
log = logging.getLogger("pollsvc")
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _salt() -> bytes:
|
||||
try:
|
||||
with open(SALT_PATH, "rb") as f:
|
||||
s = f.read()
|
||||
if len(s) >= 16:
|
||||
return s
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
s = os.urandom(32)
|
||||
with open(SALT_PATH, "wb") as f:
|
||||
f.write(s)
|
||||
os.chmod(SALT_PATH, 0o600)
|
||||
return s
|
||||
|
||||
|
||||
def _db() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS votes ("
|
||||
" poll TEXT NOT NULL, device TEXT NOT NULL, choice TEXT NOT NULL,"
|
||||
" ts INTEGER NOT NULL DEFAULT (unixepoch()),"
|
||||
" PRIMARY KEY (poll, device))"
|
||||
)
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
SALT = None
|
||||
CONN = None
|
||||
|
||||
|
||||
def counts(poll: str) -> dict:
|
||||
with _lock:
|
||||
rows = CONN.execute(
|
||||
"SELECT choice, COUNT(*) FROM votes WHERE poll=? GROUP BY choice", (poll,)
|
||||
).fetchall()
|
||||
c = {k: 0 for k in POLLS[poll]}
|
||||
for choice, n in rows:
|
||||
if choice in c:
|
||||
c[choice] = n
|
||||
return {"poll": poll, "counts": c, "total": sum(c.values())}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "furtka-poll/1"
|
||||
|
||||
def log_message(self, fmt, *args): # quiet; nginx has the access log
|
||||
log.debug(fmt, *args)
|
||||
|
||||
def _client_ip(self) -> str:
|
||||
xff = self.headers.get("X-Forwarded-For")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
return self.headers.get("X-Real-IP") or self.client_address[0]
|
||||
|
||||
def _device(self) -> str:
|
||||
ua = self.headers.get("User-Agent", "")
|
||||
h = hashlib.sha256(SALT + self._client_ip().encode() + b"|" + ua.encode())
|
||||
return h.hexdigest()[:32]
|
||||
|
||||
def _json(self, status: int, payload: dict):
|
||||
body = json.dumps(payload).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _poll(self):
|
||||
name = self.path.strip("/").split("?")[0]
|
||||
if name not in POLLS:
|
||||
self._json(404, {"error": "no such poll"})
|
||||
return None
|
||||
return name
|
||||
|
||||
def do_GET(self):
|
||||
poll = self._poll()
|
||||
if not poll:
|
||||
return
|
||||
payload = counts(poll)
|
||||
with _lock:
|
||||
row = CONN.execute(
|
||||
"SELECT choice FROM votes WHERE poll=? AND device=?", (poll, self._device())
|
||||
).fetchone()
|
||||
payload["yours"] = row[0] if row else None
|
||||
self._json(200, payload)
|
||||
|
||||
def do_POST(self):
|
||||
poll = self._poll()
|
||||
if not poll:
|
||||
return
|
||||
try:
|
||||
n = int(self.headers.get("Content-Length", "0"))
|
||||
if n > MAX_BODY:
|
||||
raise ValueError("body too large")
|
||||
data = json.loads(self.rfile.read(n) or b"{}")
|
||||
choice = str(data.get("choice", ""))
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
self._json(400, {"error": "bad request"})
|
||||
return
|
||||
if choice not in POLLS[poll]:
|
||||
self._json(400, {"error": "unknown choice"})
|
||||
return
|
||||
with _lock:
|
||||
CONN.execute(
|
||||
"INSERT INTO votes (poll, device, choice) VALUES (?,?,?)"
|
||||
" ON CONFLICT(poll, device) DO UPDATE SET choice=excluded.choice, ts=unixepoch()",
|
||||
(poll, self._device(), choice),
|
||||
)
|
||||
CONN.commit()
|
||||
payload = counts(poll)
|
||||
payload["yours"] = choice
|
||||
self._json(200, payload)
|
||||
|
||||
|
||||
def main():
|
||||
global SALT, CONN
|
||||
logging.basicConfig(
|
||||
level=os.environ.get("POLL_LOGLEVEL", "INFO"),
|
||||
stream=sys.stderr,
|
||||
format="%(levelname)s %(message)s",
|
||||
)
|
||||
os.makedirs(STATE_DIR, exist_ok=True)
|
||||
SALT = _salt()
|
||||
CONN = _db()
|
||||
srv = ThreadingHTTPServer((LISTEN, PORT), Handler)
|
||||
log.info("listening on %s:%d, db %s", LISTEN, PORT, DB_PATH)
|
||||
srv.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Install / update the poll counter on forge-runner-01. Idempotent.
|
||||
#
|
||||
# Usage (on the VM, with sudo):
|
||||
# sudo ops/poll/setup-poll.sh
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
install -d -m 0755 /opt/furtka-poll
|
||||
install -m 0644 "$HERE/pollsvc.py" /opt/furtka-poll/pollsvc.py
|
||||
install -m 0644 "$HERE/furtka-poll.service" /etc/systemd/system/furtka-poll.service
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now furtka-poll
|
||||
systemctl restart furtka-poll
|
||||
sleep 1
|
||||
systemctl --no-pager --lines=3 status furtka-poll
|
||||
curl -fsS http://127.0.0.1:8090/logo && echo
|
||||
echo "OK: furtka-poll running on 127.0.0.1:8090"
|
||||
|
|
@ -316,17 +316,14 @@ def test_link_new_units_only_links_missing(updater, tmp_path, monkeypatch):
|
|||
|
||||
linked = updater._link_new_units(unit_dir)
|
||||
assert linked == ["furtka-bar.timer"]
|
||||
# Three calls for the newly-linked timer: link + enable + start. The
|
||||
# already-linked service is untouched. `enable` alone only arms the
|
||||
# timer for the next boot; `start` makes it fire on the running box
|
||||
# (26.20 left furtka-welcome.timer dead until reboot without it).
|
||||
assert len(seen) == 3
|
||||
# Two calls for the newly-linked timer: systemctl link + systemctl enable.
|
||||
# The already-linked service is untouched. Timers need the follow-up
|
||||
# `enable` so self-updates that introduce new timers don't leave them
|
||||
# dormant — fresh installs get their enable via the webinstaller.
|
||||
assert len(seen) == 2
|
||||
assert seen[0][:2] == ["systemctl", "link"]
|
||||
# Linked via the `current` symlink, not the versioned dir being scanned,
|
||||
# so the link survives the next symlink flip / rollback.
|
||||
assert seen[0][2] == str(updater.current_symlink() / "assets" / "systemd" / "furtka-bar.timer")
|
||||
assert seen[0][2].endswith("furtka-bar.timer")
|
||||
assert seen[1] == ["systemctl", "enable", "furtka-bar.timer"]
|
||||
assert seen[2] == ["systemctl", "start", "furtka-bar.timer"]
|
||||
|
||||
|
||||
def test_extract_tarball_uses_data_filter_when_available(tmp_path, updater, monkeypatch):
|
||||
|
|
|
|||
|
|
@ -42,15 +42,6 @@ This rsyncs `website/` to `/srv/furtka-site/` on the VM over SSH and runs
|
|||
`hugo --minify` into `/var/www/furtka.org`. Same end state as the CI path,
|
||||
just with an SSH hop.
|
||||
|
||||
### Poll counter
|
||||
|
||||
`/api/poll/` is a tiny stdlib Python service (`ops/poll/pollsvc.py`,
|
||||
SQLite, systemd `DynamicUser`) behind nginx on `127.0.0.1:8090`. Install or
|
||||
update it on the VM with `sudo ops/poll/setup-poll.sh`; the nginx location
|
||||
and rate limit come from `ops/nginx/` via `setup-vm.sh`. Devices are told
|
||||
apart by a salted hash of IP + user agent — no cookie — and a repeat vote
|
||||
from the same device changes the choice instead of adding one.
|
||||
|
||||
### First-time VM setup
|
||||
|
||||
Only needed once, when provisioning a fresh forge-runner VM:
|
||||
|
|
@ -67,14 +58,11 @@ hugo.toml Hugo config (multilingual: en default, de)
|
|||
content/ Markdown pages
|
||||
_index.md Home (EN)
|
||||
_index.de.md Home (DE)
|
||||
logo.md / .de.md Logo poll (/logo/, /de/logo/) — layout `poll`, votes via /api/poll/
|
||||
layouts/ Custom inline theme — no external theme or framework
|
||||
_default/ baseof, single, list
|
||||
partials/ head, header, footer, gate SVG, lang switcher, mark-m / mark-r (poll)
|
||||
poll/single.html Logo poll page; strings come from the content front matter
|
||||
partials/ head, header, footer, gate SVG, lang switcher
|
||||
index.html Home-only layout with editorial hero
|
||||
assets/css/main.css Stylesheet (fingerprinted + minified on build)
|
||||
assets/js/poll.js Vote script for /logo/ — plain fetch, no cookies, no storage
|
||||
static/favicon.svg Gate mark in crimson
|
||||
deploy.sh Manual rsync + remote Hugo build (over SSH, for off-CI pushes)
|
||||
deploy-ci.sh Local rsync + Hugo build — runs on forge-runner-01 from CI
|
||||
|
|
|
|||
|
|
@ -439,92 +439,3 @@ main.container {
|
|||
will-change: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Logo poll ───────────────────────────────────────────────── */
|
||||
|
||||
.poll-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 2.5rem;
|
||||
}
|
||||
.poll-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
padding: 1.4rem;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 1rem;
|
||||
background: var(--card-bg);
|
||||
transition: border-color 160ms, box-shadow 160ms;
|
||||
}
|
||||
.poll-card.is-yours {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||
}
|
||||
.poll-card h2 { margin: 0; font-size: 1.25rem; letter-spacing: -0.01em; }
|
||||
.poll-desc { margin: 0; color: var(--fg-muted); font-size: 0.95rem; }
|
||||
.poll-art {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 2.2rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
.poll-art--m { background: var(--bg-subtle); color: var(--accent); }
|
||||
.poll-art--r { background: #151515; color: #af6428; }
|
||||
.pm { display: block; }
|
||||
.pm--big { height: 9rem; width: auto; }
|
||||
.pm--head { height: 1.1em; width: auto; }
|
||||
.poll-ctx {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
}
|
||||
.poll-ctx-head, .poll-ctx-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--bg);
|
||||
font-size: 0.82rem;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.poll-ctx-head { color: var(--accent); }
|
||||
.poll-ctx-head--r { color: #af6428; }
|
||||
.poll-ctx-name {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.7rem;
|
||||
color: var(--fg);
|
||||
}
|
||||
.poll-ctx-tab img { image-rendering: pixelated; }
|
||||
.poll-btn {
|
||||
margin-top: auto;
|
||||
padding: 0.85rem 1.2rem;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 0.6rem;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 140ms, transform 140ms;
|
||||
}
|
||||
.poll-btn:hover:not(:disabled) { background: var(--accent-hover); transform: translateY(-1px); }
|
||||
.poll-btn:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
|
||||
.poll-btn:disabled { cursor: default; opacity: 0.85; }
|
||||
.is-yours .poll-btn { background: transparent; color: var(--accent); }
|
||||
.poll-bar { height: 0.45rem; border-radius: 999px; background: var(--bg-subtle); overflow: hidden; display: none; }
|
||||
.poll-bar i { display: block; height: 100%; width: 0; background: var(--accent); transition: width 500ms ease; }
|
||||
.poll--voted .poll-bar { display: block; }
|
||||
.poll-count { margin: 0; min-height: 1.4em; font-size: 0.85rem; color: var(--fg-muted); font-variant-numeric: tabular-nums; }
|
||||
.poll-status { margin: 1.5rem 0 0; min-height: 1.5em; color: var(--fg-muted); }
|
||||
.poll-note { margin-top: 1rem; max-width: var(--measure); font-size: 0.85rem; color: var(--fg-muted); }
|
||||
.poll-note a { color: var(--accent); }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.poll-bar i, .poll-btn { transition: none; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
// furtka.org logo poll — no cookies, no storage. The server tells us
|
||||
// what this device already chose (salted hash of IP + user agent).
|
||||
(function () {
|
||||
var root = document.querySelector('.poll');
|
||||
if (!root || !window.fetch) return;
|
||||
var api = root.dataset.api;
|
||||
var status = root.querySelector('.poll-status');
|
||||
var t = status.dataset;
|
||||
var buttons = root.querySelectorAll('[data-vote]');
|
||||
var busy = false;
|
||||
|
||||
function pct(n, total) { return total ? Math.round((n / total) * 100) : 0; }
|
||||
|
||||
function render(d) {
|
||||
var total = d.total || 0;
|
||||
root.classList.toggle('poll--voted', !!d.yours);
|
||||
Object.keys(d.counts).forEach(function (k) {
|
||||
var card = root.querySelector('[data-choice="' + k + '"]');
|
||||
if (!card) return;
|
||||
var n = d.counts[k], p = pct(n, total);
|
||||
card.classList.toggle('is-yours', d.yours === k);
|
||||
card.querySelector('.poll-bar i').style.width = p + '%';
|
||||
card.querySelector('[data-count]').textContent = d.yours ? p + ' % · ' + n : '';
|
||||
var b = card.querySelector('[data-vote]');
|
||||
b.disabled = d.yours === k;
|
||||
b.textContent = d.yours === k ? t.tVoted : (d.yours ? t.tChange : b.dataset.label);
|
||||
});
|
||||
status.textContent = d.yours ? t.tTotal.replace('{n}', total) : '';
|
||||
}
|
||||
|
||||
function load() {
|
||||
fetch(api, { headers: { Accept: 'application/json' } })
|
||||
.then(function (r) { if (!r.ok) throw r; return r.json(); })
|
||||
.then(render)
|
||||
.catch(function () { status.textContent = t.tError; });
|
||||
}
|
||||
|
||||
buttons.forEach(function (b) {
|
||||
b.dataset.label = b.textContent;
|
||||
b.addEventListener('click', function () {
|
||||
if (busy) return;
|
||||
busy = true; b.disabled = true;
|
||||
fetch(api, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ choice: b.dataset.vote })
|
||||
})
|
||||
.then(function (r) { if (!r.ok) throw r; return r.json(); })
|
||||
.then(render)
|
||||
.catch(function () { status.textContent = t.tError; b.disabled = false; })
|
||||
.then(function () { busy = false; });
|
||||
});
|
||||
});
|
||||
|
||||
status.textContent = '';
|
||||
load();
|
||||
})();
|
||||
|
|
@ -11,8 +11,7 @@ Diese Website setzt **keine Cookies**, lädt **keine Schriften oder
|
|||
Skripte von Drittanbietern**, bindet **keine Analyse- oder
|
||||
Tracking-Dienste** ein und enthält **keine externen Einbettungen**
|
||||
(YouTube, Maps, Social-Media-Buttons, …). Technisch anfallend sind
|
||||
ausschließlich kurzfristige Server-Zugriffsprotokolle — und, solange die
|
||||
[Logo-Abstimmung](/de/logo/) läuft, ein gesalzener Hash pro Stimme (siehe unten).
|
||||
ausschließlich kurzfristige Server-Zugriffsprotokolle.
|
||||
|
||||
### Verantwortlicher
|
||||
|
||||
|
|
@ -44,22 +43,6 @@ an Betrieb und Sicherheit).
|
|||
Infrastruktur; es gibt keinen externen Auftragsverarbeiter.
|
||||
**Drittlandübermittlung:** keine.
|
||||
|
||||
### Logo-Abstimmung
|
||||
|
||||
Auf der Seite [/de/logo/](/de/logo/) können Sie zwischen zwei
|
||||
Logo-Vorschlägen abstimmen. Damit jedes Gerät ohne Cookie einmal gezählt
|
||||
wird, speichert der Abstimmungsserver einen **gesalzenen SHA-256-Hash aus
|
||||
Ihrer IP-Adresse und Browser-Kennung** zusammen mit Ihrer Wahl und dem
|
||||
Zeitpunkt der Stimmabgabe. Der Hash lässt sich nicht in die Adresse
|
||||
zurückrechnen; das Salt verlässt den Server nicht.
|
||||
|
||||
**Zweck:** Zählung je Gerät einmal.
|
||||
**Rechtsgrundlage:** Art. 6 Abs. 1 lit. f DSGVO — berechtigtes Interesse
|
||||
an einer ehrlichen Auszählung; die Teilnahme ist freiwillig.
|
||||
**Speicherdauer:** bis zum Ende der Abstimmung, danach wird die Datenbank
|
||||
gelöscht.
|
||||
**Empfänger:** keine.
|
||||
|
||||
### Cookies und Tracking
|
||||
|
||||
Keine. Es werden keine Cookies gesetzt, kein LocalStorage oder
|
||||
|
|
@ -92,5 +75,5 @@ Website: <https://www.datenschutz.rlp.de>
|
|||
|
||||
### Stand
|
||||
|
||||
Diese Erklärung ist aktuell gültig und wurde zuletzt am 26.08.2026
|
||||
Diese Erklärung ist aktuell gültig und wurde zuletzt am 18.04.2026
|
||||
aktualisiert.
|
||||
|
|
|
|||
|
|
@ -11,8 +11,7 @@ sitemap:
|
|||
This website sets **no cookies**, loads **no third-party fonts or
|
||||
scripts**, embeds **no analytics or tracking services**, and contains
|
||||
**no external embeds** (YouTube, Maps, social buttons, …). The only
|
||||
technical data collected is short-lived server access logs — plus, while
|
||||
the [logo poll](/logo/) is running, one salted hash per vote (see below).
|
||||
technical data collected is short-lived server access logs.
|
||||
|
||||
### Controller
|
||||
|
||||
|
|
@ -44,20 +43,6 @@ and security.
|
|||
external processor is involved.
|
||||
**Transfers outside the EU/EEA:** none.
|
||||
|
||||
### Logo poll
|
||||
|
||||
The page [/logo/](/logo/) lets you vote between two logo proposals. So
|
||||
that each device is counted once without a cookie, the poll server stores
|
||||
a **salted SHA-256 hash of your IP address and browser identification**
|
||||
together with your choice and the time of the vote. The hash cannot be
|
||||
turned back into the address; the salt never leaves the server.
|
||||
|
||||
**Purpose:** counting votes once per device.
|
||||
**Legal basis:** Art. 6(1)(f) GDPR — legitimate interest in an honest
|
||||
tally; voting is voluntary.
|
||||
**Retention:** until the poll is closed, then the database is deleted.
|
||||
**Recipients:** none.
|
||||
|
||||
### Cookies and tracking
|
||||
|
||||
None. No cookies are set, no localStorage or sessionStorage is used, and
|
||||
|
|
@ -90,6 +75,6 @@ Website: <https://www.datenschutz.rlp.de>
|
|||
|
||||
### Last updated
|
||||
|
||||
This statement was last updated on 2026-08-26.
|
||||
This statement was last updated on 2026-04-18.
|
||||
|
||||
The German version of this privacy statement is the legally binding one.
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
---
|
||||
title: "Welches Logo?"
|
||||
description: "Zwei Vorschläge für das Furtka-Zeichen. Wähl das, das dir besser gefällt — ein Klick genügt."
|
||||
translationKey: "logo"
|
||||
layout: single
|
||||
type: poll
|
||||
poll: logo
|
||||
sitemap:
|
||||
priority: 0.5
|
||||
m:
|
||||
title: "M — das f im Tor"
|
||||
text: "Ein kleines f, aus einem Rundbogentor ausgestanzt. Der Stamm ist der Torpfosten, die zwei Balken sind die Querriegel."
|
||||
r:
|
||||
title: "R — Fenster, Rad und F"
|
||||
text: "Ein Bogenfenster mit einem Speichenrad dahinter; in der oberen rechten Scheibe wird die Speiche zum F."
|
||||
labels:
|
||||
vote: "Dieses hier"
|
||||
voted: "Deine Wahl"
|
||||
change: "Zu diesem wechseln"
|
||||
total: "Bisher {n} Stimmen. Danke!"
|
||||
error: "Die Abstimmung ist gerade nicht erreichbar — bitte gleich noch einmal versuchen."
|
||||
nojs: "Zum Abstimmen wird JavaScript benötigt."
|
||||
note: "Keine Cookies, kein Konto. Damit jedes Gerät einmal zählt, speichert der Server einen gesalzenen Hash aus IP-Adresse und Browser — nicht die Adresse selbst. Details in der <a href=\"/de/datenschutz/\">Datenschutzerklärung</a>."
|
||||
---
|
||||
|
||||
*Furtka* ist polnisch für die kleine Gartenpforte — die Tür im Zaun, durch
|
||||
die man hineinkommt, ohne das große Tor aufzumachen. Beide Zeichen spielen
|
||||
damit: das eine zeichnet den Buchstaben ins Tor, das andere schaut durchs
|
||||
Tor auf ein Rad. Unter jedem Zeichen siehst du, wie es in der Kopfzeile
|
||||
der Seite sitzt und als echtes 16-Pixel-Symbol im Browser-Tab.
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
---
|
||||
title: "Which logo?"
|
||||
description: "Two proposals for the Furtka mark. Pick the one you like better — it takes one click."
|
||||
translationKey: "logo"
|
||||
url: /logo/
|
||||
layout: single
|
||||
type: poll
|
||||
poll: logo
|
||||
sitemap:
|
||||
priority: 0.5
|
||||
m:
|
||||
title: "M — the f in the gate"
|
||||
text: "A lowercase f cut out of an arched gate. The stem is the gate post, the two rails are the crossbars."
|
||||
r:
|
||||
title: "R — window, wheel and F"
|
||||
text: "An arched window with a wheel of spokes behind it; in the top-right pane the spoke becomes an F."
|
||||
labels:
|
||||
vote: "This one"
|
||||
voted: "Your pick"
|
||||
change: "Switch to this one"
|
||||
total: "{n} votes so far. Thank you!"
|
||||
error: "Couldn't reach the poll right now — please try again in a moment."
|
||||
nojs: "Voting needs JavaScript."
|
||||
note: "No cookies, no account. To count each device once, the server keeps a salted hash of your IP address and browser — not the address itself. Details in the <a href=\"/privacy/\">privacy notice</a>."
|
||||
---
|
||||
|
||||
*Furtka* is Polish for a small garden gate — the little door in the fence
|
||||
that lets you in without opening the whole thing. Both marks play with that:
|
||||
one draws the letter into the gate, the other looks through the gate at a
|
||||
wheel. Below each mark you see how it sits in the site header and as a
|
||||
real 16-pixel browser-tab icon.
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
{{- /* Mark M — lowercase f cut out of a gate. 64-unit grid, even-odd knockout. */ -}}
|
||||
<svg class="pm {{ .class }}" viewBox="0 0 64 64" aria-hidden="true" focusable="false"><path fill="currentColor" fill-rule="evenodd" d="M6 60V28a26 26 0 0 1 52 0v32Z M20.0 60V26.5A10.5 10.5 0 0 1 30.5 16H45.0v8.6H32.5a3.5 3.5 0 0 0-3.5 3.5V32h16v8.6H29.0V60Z"/></svg>
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
{{- /* Mark R — window, wheel and F. 104x114 grid, stroke 8, hub knocked out. */ -}}
|
||||
<svg class="pm {{ .class }}" viewBox="0 0 104 114" aria-hidden="true" focusable="false"><defs><clipPath id="pm-rc-{{ .id }}" clip-rule="evenodd"><path clip-rule="evenodd" d="M2 112V42A40 40 0 0 1 42 2H62A40 40 0 0 1 102 42V112Z M63 65a11 11 0 1 0-22 0a11 11 0 1 0 22 0Z"/></clipPath></defs><g fill="none" stroke="currentColor" stroke-width="8" stroke-linejoin="round"><path d="M6 108V42A36 36 0 0 1 42 6H62A36 36 0 0 1 98 42V108Z"/><path d="M52 8V106 M8 65H96 M52 65L4 31.1 M52 65L4 98.9 M52 65L100 98.9" clip-path="url(#pm-rc-{{ .id }})"/><path d="M52 22H79M52 37H69" stroke-linecap="round"/><circle cx="52" cy="65" r="15"/></g></svg>
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
{{ define "main" }}
|
||||
{{- $p := .Params -}}
|
||||
<article class="poll" data-poll="{{ $p.poll }}" data-api="/api/poll/{{ $p.poll }}">
|
||||
<header class="page-header">
|
||||
<h1>{{ .Title }}</h1>
|
||||
{{ with $p.description }}<p class="lede">{{ . }}</p>{{ end }}
|
||||
</header>
|
||||
<div class="prose">{{ .Content }}</div>
|
||||
|
||||
<div class="poll-grid">
|
||||
<section class="poll-card" data-choice="m" aria-labelledby="poll-m-title">
|
||||
<div class="poll-art poll-art--m">{{ partial "mark-m.html" (dict "class" "pm--big" "id" "m") }}</div>
|
||||
<div class="poll-ctx">
|
||||
<span class="poll-ctx-head">{{ partial "mark-m.html" (dict "class" "pm--head" "id" "mh") }}<span class="poll-ctx-name">Furtka</span></span>
|
||||
<span class="poll-ctx-tab"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAABL0lEQVQ4jcWRP0tCYRTGf+d6+0dDbQ0RuAQaNYdXIi1ssCUQt4YkWvsK9Rn6DNHclEsoDt2oWVDEoCEaWmoKutk9Dd6rLzczwaHf9r7nPM953vMKEa7XVxfsWOcYZQ9hObhuKXI5iXeWdh9ezH4xDxUnuWvhn4PMR40DXkVkf/OmcfXDoJJa2bbELwMTv4hDPF/Z2bpt1noG1Ux8WrypFsjSH+KQx/e5TiJfbn/YAOLNFEENsdwrHGbdRt1UVZ3EqcAJEJ99swvAhdUt+Tmjr6Nfn4WoOIoKOQA7mLho1J6zd+0ngFoq4SCy0RdpGg0UQeKugWIZ/+EbU45AD/pjjQQqMSPBYDJuswSUBuyghzXMYBSGJqilk3mUtfBs7mAkA1UtAgN3EDL2E/7fYGy+AWGMWfwfY1MaAAAAAElFTkSuQmCC" width="16" height="16" alt="">Furtka</span>
|
||||
</div>
|
||||
<h2 id="poll-m-title">{{ $p.m.title }}</h2>
|
||||
<p class="poll-desc">{{ $p.m.text }}</p>
|
||||
<button type="button" class="poll-btn" data-vote="m">{{ $p.labels.vote }}</button>
|
||||
<div class="poll-bar" aria-hidden="true"><i></i></div>
|
||||
<p class="poll-count" data-count="m"></p>
|
||||
</section>
|
||||
|
||||
<section class="poll-card" data-choice="r" aria-labelledby="poll-r-title">
|
||||
<div class="poll-art poll-art--r">{{ partial "mark-r.html" (dict "class" "pm--big" "id" "r") }}</div>
|
||||
<div class="poll-ctx">
|
||||
<span class="poll-ctx-head poll-ctx-head--r">{{ partial "mark-r.html" (dict "class" "pm--head" "id" "rh") }}<span class="poll-ctx-name">Furtka</span></span>
|
||||
<span class="poll-ctx-tab"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAQCAYAAADJViUEAAAABmJLR0QA/wD/AP+gvaeTAAAB30lEQVQokY2TPUhVYRzGf8855/ox9LHZVkPkrZYsIegDnQPBey1zcKi8ObY0tjTk2hJEXK85FE3pzdUpiyiIIgRJxSiIQEgCl7p6Pedp8GhHuYHP+P8/v+d93hdekdFkKV8MxA2bDqA1s/oDfDAeK1YWpraGAhi/dqTlQNTyTFAQLBvPWLQGqDvBL+WgBu4C2gzPW1o3Bi89WFoLAA7mmsuCXuDu+mp4uFBZGMA8ttmPqdnJV9tPsKYEl9dquYcAejF84qyT5B3mfmFs/vZWpYmhY6VAwSg79Vbw3nAL6AwSJ1eBep1wJOsKk+A7gO0RScV6U7ivUJk/tx5v3AM2LAYC2e2gpf6xuV9ZOAl1FEDSHduTUT0eBugfX/oJfJHJB6Ac8noWNEgkbembPg1julfrtYfbBlEDmiMaqFpqLwuV0qjBOOT39cq3md0+VUv5acFJpDebfcklcq+yJjDWFAH1zTyfN8xFac0m7DObToXadYJBiA7sOB01AURp9I/C6Pyp7dpDx18jX8jAr4qVz13b+5v5TxiCRneOwrhgqKbkRC5I+hr5GsI95cUVJTxK4XJPeXFlz/BepWopPw2cBiayC8uH/n0MLe/i+oCPETALdAJXdqRaGGKhi/85ePYvrfW+Wa23Y78AAAAASUVORK5CYII=" width="15" height="16" alt="">Furtka</span>
|
||||
</div>
|
||||
<h2 id="poll-r-title">{{ $p.r.title }}</h2>
|
||||
<p class="poll-desc">{{ $p.r.text }}</p>
|
||||
<button type="button" class="poll-btn" data-vote="r">{{ $p.labels.vote }}</button>
|
||||
<div class="poll-bar" aria-hidden="true"><i></i></div>
|
||||
<p class="poll-count" data-count="r"></p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p class="poll-status" role="status" aria-live="polite"
|
||||
data-t-voted="{{ $p.labels.voted }}"
|
||||
data-t-total="{{ $p.labels.total }}"
|
||||
data-t-error="{{ $p.labels.error }}"
|
||||
data-t-nojs="{{ $p.labels.nojs }}"
|
||||
data-t-change="{{ $p.labels.change }}">{{ $p.labels.nojs }}</p>
|
||||
<p class="poll-note">{{ $p.labels.note | safeHTML }}</p>
|
||||
</article>
|
||||
{{ $js := resources.Get "js/poll.js" | minify | fingerprint }}
|
||||
<script defer src="{{ $js.RelPermalink }}" integrity="{{ $js.Data.Integrity }}"></script>
|
||||
{{ end }}
|
||||
Loading…
Add table
Reference in a new issue