module.py
floor_plan version 1.0 · 11 KB
| 1 | """Floor plan custom module: a clickable map of the building with its areas and cameras. |
| 2 | |
| 3 | web(api) the Map page (/x/floor_plan/), the floor plan pictures (/x/floor_plan/plan/<id>) and the API |
| 4 | (/api/x/floor_plan/...). Everyone signed in can use the map - they only get the cameras they may see; making and |
| 5 | editing maps is for admins. No recorder part: live video and recordings are the NVR's own (the page uses them). |
| 6 | """ |
| 7 | import json |
| 8 | import logging |
| 9 | import os |
| 10 | import secrets |
| 11 | import shutil |
| 12 | import time |
| 13 | |
| 14 | from flask import abort, request, send_file |
| 15 | |
| 16 | from .mapdata import DEFAULT_ASPECT, MAX_NAME, MAX_PLAN_BYTES, PLAN_TYPES, clean, for_viewer, plan_info |
| 17 | |
| 18 | TABLE = """CREATE TABLE IF NOT EXISTS floor_plans ( |
| 19 | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 20 | name TEXT NOT NULL, |
| 21 | data TEXT NOT NULL, -- {"sections": [...], "cameras": [...]} (mapdata.py) |
| 22 | plan TEXT, -- the floor plan picture's file name in the module's data folder (NULL = none yet) |
| 23 | plan_w INTEGER, |
| 24 | plan_h INTEGER, |
| 25 | aspect REAL NOT NULL, -- height / width of the map (the plan's, or DEFAULT_ASPECT) |
| 26 | version INTEGER NOT NULL DEFAULT 1, -- +1 on every save: two people editing at once can't overwrite each other unseen |
| 27 | updated_at REAL NOT NULL |
| 28 | )""" |
| 29 | MAX_MAPS = 50 |
| 30 | LOG = logging.getLogger("localeye.custom.floor_plan") |
| 31 | OLD_ID, OLD_TABLE = "warehouse_map", "warehouse_maps" # this module's old name: its maps and pictures are brought over |
| 32 | |
| 33 | |
| 34 | def _bring_over(api, plans): |
| 35 | """A server that had the module as "Warehouse map": move its maps into floor_plans (only while floor_plans is still empty; |
| 36 | copied and the old table emptied in one go - same ids, so the pictures' names still match) and copy the floor plan pictures |
| 37 | from the old data folder (any a map uses that aren't here yet). The old data folder is left as it was. (The old table stays, |
| 38 | empty: a module can't drop a table with AUTOINCREMENT - SQLite would have to touch sqlite_sequence, which the fence refuses.)""" |
| 39 | if api.db.one("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (OLD_TABLE,)): |
| 40 | moved = api.db.one(f"SELECT COUNT(*) AS n FROM {OLD_TABLE}")["n"] |
| 41 | if moved and not api.db.one("SELECT COUNT(*) AS n FROM floor_plans")["n"]: |
| 42 | api.db.script(f"""BEGIN; |
| 43 | INSERT INTO floor_plans(id, name, data, plan, plan_w, plan_h, aspect, version, updated_at) |
| 44 | SELECT id, name, data, plan, plan_w, plan_h, aspect, version, updated_at FROM {OLD_TABLE}; |
| 45 | DELETE FROM {OLD_TABLE}; |
| 46 | COMMIT;""") |
| 47 | LOG.info(f"brought {moved} map(s) over from Warehouse map (this module's old name)") |
| 48 | old = os.path.join(os.path.dirname(api.data_dir), OLD_ID, "plans") |
| 49 | if not os.path.isdir(old): |
| 50 | return |
| 51 | for r in api.db.query("SELECT plan FROM floor_plans WHERE plan IS NOT NULL"): |
| 52 | name = os.path.basename(r["plan"]) |
| 53 | src, dst = os.path.join(old, name), os.path.join(plans, name) |
| 54 | if os.path.isfile(src) and not os.path.exists(dst): |
| 55 | os.makedirs(plans, exist_ok=True) |
| 56 | shutil.copyfile(src, dst + ".part") |
| 57 | os.replace(dst + ".part", dst) |
| 58 | |
| 59 | |
| 60 | def web(api): |
| 61 | api.db.script(TABLE) |
| 62 | plans = os.path.join(api.data_dir, "plans") |
| 63 | try: |
| 64 | _bring_over(api, plans) |
| 65 | except Exception as exc: # never stop the page over it (the move is all or nothing) |
| 66 | LOG.warning(f"couldn't bring the maps over from Warehouse map: {exc}") |
| 67 | |
| 68 | def cams(): |
| 69 | """The cameras the person asking may see, view-only ones too (live only, no recordings): [{stream, name, view_only?}]. |
| 70 | An NVR from before cameras(view_only=...) gives just the recording ones.""" |
| 71 | try: |
| 72 | return api.cameras(view_only=True) |
| 73 | except TypeError: |
| 74 | return api.cameras() |
| 75 | |
| 76 | def known(): |
| 77 | """Streams the person asking may see (admins: every camera) -> name.""" |
| 78 | return {c["stream"]: c["name"] for c in cams()} |
| 79 | |
| 80 | def get(mid): |
| 81 | row = api.db.one("SELECT * FROM floor_plans WHERE id=?", (mid,)) |
| 82 | if not row: |
| 83 | abort(404) |
| 84 | return row |
| 85 | |
| 86 | def public(row, full=True): |
| 87 | out = {"id": row["id"], "name": row["name"], "aspect": row["aspect"], "version": row["version"], |
| 88 | "plan": f"/x/{api.id}/plan/{row['id']}?v={row['version']}" if row["plan"] else None, |
| 89 | "plan_size": [row["plan_w"], row["plan_h"]] if row["plan"] else None, "updated_at": row["updated_at"]} |
| 90 | if full: |
| 91 | try: |
| 92 | data = json.loads(row["data"] or "{}") |
| 93 | except ValueError: |
| 94 | data = {} |
| 95 | out["data"] = for_viewer(data, set(known())) |
| 96 | return out |
| 97 | |
| 98 | def body(): |
| 99 | b = request.get_json(silent=True) |
| 100 | return b if isinstance(b, dict) else {} |
| 101 | |
| 102 | @api.route("/") |
| 103 | def page(): |
| 104 | return api.render("map.html", can_edit=api.is_admin(), version=api.manifest.get("version", "1")) |
| 105 | |
| 106 | @api.route("/api/maps") |
| 107 | def maps(): |
| 108 | rows = api.db.query("SELECT * FROM floor_plans ORDER BY name COLLATE NOCASE, id") |
| 109 | return {"ok": True, "maps": [public(r, full=False) for r in rows], "can_edit": api.is_admin(), |
| 110 | "cameras": [{"stream": c["stream"], "name": c["name"], "view_only": bool(c.get("view_only"))} for c in cams()]} |
| 111 | |
| 112 | @api.route("/api/maps/<int:mid>") |
| 113 | def one_map(mid): |
| 114 | return {"ok": True, "map": public(get(mid))} |
| 115 | |
| 116 | @api.route("/api/maps", methods=["POST"], admin=True) |
| 117 | def create(): |
| 118 | if api.db.one("SELECT COUNT(*) AS n FROM floor_plans")["n"] >= MAX_MAPS: |
| 119 | return {"ok": False, "error": f"{MAX_MAPS} maps at most"}, 400 |
| 120 | name = " ".join(str(body().get("name") or "").split())[:MAX_NAME] or "Main floor" |
| 121 | mid, _ = api.db.execute("INSERT INTO floor_plans(name, data, aspect, updated_at) VALUES(?,?,?,?)", |
| 122 | (name, json.dumps({"sections": [], "cameras": []}), DEFAULT_ASPECT, time.time())) |
| 123 | api.audit("map_created", name, id=mid) |
| 124 | return {"ok": True, "map": public(get(mid))} |
| 125 | |
| 126 | @api.route("/api/maps/<int:mid>", methods=["PUT"], admin=True) |
| 127 | def save(mid): |
| 128 | row, b = get(mid), body() |
| 129 | if b.get("version") != row["version"]: |
| 130 | return {"ok": False, "conflict": True, |
| 131 | "error": "Someone else saved this map while you were editing it. Reload to see their changes (yours aren't saved)."}, 409 |
| 132 | try: |
| 133 | data = clean(b.get("data"), set(known())) |
| 134 | except ValueError as exc: |
| 135 | return {"ok": False, "error": f"Not saved: {exc}"}, 400 |
| 136 | name = " ".join(str(b.get("name") or row["name"]).split())[:MAX_NAME] or row["name"] |
| 137 | n = api.db.execute("UPDATE floor_plans SET name=?, data=?, version=version+1, updated_at=? WHERE id=? AND version=?", |
| 138 | (name, json.dumps(data, separators=(",", ":")), time.time(), mid, row["version"]))[1] |
| 139 | if not n: |
| 140 | return {"ok": False, "conflict": True, "error": "Someone else saved this map at the same moment. Reload to see their changes."}, 409 |
| 141 | api.audit("map_saved", name, id=mid, areas=len(data["sections"]), cameras=len(data["cameras"])) |
| 142 | return {"ok": True, "map": public(get(mid))} |
| 143 | |
| 144 | @api.route("/api/maps/<int:mid>", methods=["DELETE"], admin=True) |
| 145 | def delete(mid): |
| 146 | row = get(mid) |
| 147 | _remove_plan(row) |
| 148 | api.db.execute("DELETE FROM floor_plans WHERE id=?", (mid,)) |
| 149 | _sweep() |
| 150 | api.audit("map_deleted", row["name"], id=mid) |
| 151 | return {"ok": True} |
| 152 | |
| 153 | # ---------------------------------------------------------------- the floor plan picture |
| 154 | def _remove_plan(row): |
| 155 | if row["plan"]: |
| 156 | try: |
| 157 | os.remove(os.path.join(plans, row["plan"])) |
| 158 | except OSError: |
| 159 | pass # e.g. Windows: someone is downloading it right now - _sweep() gets it later |
| 160 | |
| 161 | def _sweep(): |
| 162 | """Delete pictures no map uses any more (one that couldn't be deleted at the time, a half-finished upload).""" |
| 163 | try: |
| 164 | names = os.listdir(plans) |
| 165 | except OSError: |
| 166 | return |
| 167 | used = {r["plan"] for r in api.db.query("SELECT plan FROM floor_plans WHERE plan IS NOT NULL")} |
| 168 | for n in names: |
| 169 | path = os.path.join(plans, n) |
| 170 | try: |
| 171 | if n not in used and not (n.endswith(".part") and time.time() - os.path.getmtime(path) < 3600): |
| 172 | os.remove(path) |
| 173 | except OSError: |
| 174 | pass |
| 175 | |
| 176 | @api.route("/api/maps/<int:mid>/plan", methods=["POST"], admin=True) |
| 177 | def upload_plan(mid): |
| 178 | row = get(mid) |
| 179 | if (request.content_length or 0) > MAX_PLAN_BYTES + 64 * 1024: |
| 180 | return {"ok": False, "error": f"That picture is too big ({MAX_PLAN_BYTES // (1024 * 1024)} MB at most)"}, 413 |
| 181 | f = request.files.get("plan") |
| 182 | if f is None: |
| 183 | return {"ok": False, "error": "Choose a picture of the floor plan first"}, 400 |
| 184 | data = f.read(MAX_PLAN_BYTES + 1) |
| 185 | if len(data) > MAX_PLAN_BYTES: |
| 186 | return {"ok": False, "error": f"That picture is too big ({MAX_PLAN_BYTES // (1024 * 1024)} MB at most)"}, 413 |
| 187 | try: |
| 188 | kind, w, h = plan_info(data[:256 * 1024]) |
| 189 | except ValueError as exc: |
| 190 | return {"ok": False, "error": str(exc)}, 400 |
| 191 | os.makedirs(plans, exist_ok=True) |
| 192 | name = f"map{mid}-{secrets.token_hex(6)}.{kind}" |
| 193 | tmp = os.path.join(plans, name + ".part") |
| 194 | with open(tmp, "wb") as out: |
| 195 | out.write(data) |
| 196 | os.replace(tmp, os.path.join(plans, name)) |
| 197 | _remove_plan(row) |
| 198 | api.db.execute("UPDATE floor_plans SET plan=?, plan_w=?, plan_h=?, aspect=?, version=version+1, updated_at=? WHERE id=?", |
| 199 | (name, w, h, round(h / w, 5), time.time(), mid)) |
| 200 | _sweep() |
| 201 | api.audit("plan_uploaded", row["name"], id=mid, size=f"{w}x{h}") |
| 202 | return {"ok": True, "map": public(get(mid))} |
| 203 | |
| 204 | @api.route("/api/maps/<int:mid>/plan", methods=["DELETE"], admin=True) |
| 205 | def remove_plan(mid): |
| 206 | row = get(mid) |
| 207 | _remove_plan(row) |
| 208 | api.db.execute("UPDATE floor_plans SET plan=NULL, plan_w=NULL, plan_h=NULL, version=version+1, updated_at=? WHERE id=?", |
| 209 | (time.time(), mid)) |
| 210 | _sweep() |
| 211 | api.audit("plan_removed", row["name"], id=mid) |
| 212 | return {"ok": True, "map": public(get(mid))} |
| 213 | |
| 214 | @api.route("/plan/<int:mid>") |
| 215 | def plan_file(mid): |
| 216 | row = get(mid) |
| 217 | if not row["plan"]: |
| 218 | abort(404) |
| 219 | path = os.path.join(plans, os.path.basename(row["plan"])) |
| 220 | if not os.path.isfile(path): |
| 221 | abort(404) |
| 222 | resp = send_file(path, mimetype=PLAN_TYPES.get(row["plan"].rsplit(".", 1)[-1], "application/octet-stream"), conditional=True) |
| 223 | resp.headers["Cache-Control"] = "private, max-age=86400" # the URL changes (?v=) whenever the picture does |
| 224 | resp.headers["X-Content-Type-Options"] = "nosniff" |
| 225 | return resp |