1 Commits

Author SHA1 Message Date
0ab5fc4570 Add contained items tree view 2025-07-19 03:28:56 +03:00
11 changed files with 384 additions and 45 deletions

View File

@@ -4,17 +4,12 @@ RUN apt-get update \
curl ca-certificates iputils-ping \
python3-ldap python3-pip python3-ldap \
libjpeg-dev libturbojpeg0-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
COPY requirements.txt ./
#necessary hack for cffi
RUN pip3 install cffi
RUN pip3 install -r requirements.txt
ENV PYTHONUNBUFFERED=1
ENV ENVIRONMENT_TYPE=PROD
COPY inventory-app /app
WORKDIR /app
COPY inventory-app .
ENTRYPOINT ["python3", "/app/main.py"]
ENTRYPOINT /app/main.py

View File

@@ -10,5 +10,3 @@
|k-space:inventory:audit|Update last time item information confirmed to be accurate|
|k-space:inventory:edit|Edit all non-key items. Browse items with Protected visibility.|
|k-space:inventory:keys|Edit keys|
For door access, assumes `k-space:floor` and `k-space:workshop`, same in doorboy-proxy.

View File

@@ -1,4 +1,3 @@
# This is unmaintained. See git.k-space.ee/k-space/kube//hackerspace/inventory.yaml
---
apiVersion: apps/v1
kind: Deployment
@@ -24,6 +23,14 @@ spec:
env:
- name: OIDC_USERS_NAMESPACE
value: "default"
- name: SLACK_DOORLOG_CALLBACK
value: "changeme"
- name: SLACK_VERIFICATION_TOKEN
value: "changeme"
- name: INVENTORY_API_KEY
value: "sptWL6XFxl4b8"
- name: PYTHONUNBUFFERED
value: "1"
# Google test key
- name: RECAPTCHA_PUBLIC_KEY
value: 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI
@@ -41,6 +48,8 @@ spec:
secretKeyRef:
name: miniobucket-inventory-app-owner-secrets
key: MINIO_URI
- name: SECRET_KEY
value: "bad_secret"
- name: ENVIRONMENT_TYPE
value: "DEV"
- name: MY_POD_NAME

117
inventory-app/api.py Normal file
View File

@@ -0,0 +1,117 @@
import re
import const
import time
import threading
from datetime import datetime
from functools import wraps
from pymongo import MongoClient
from flask import Blueprint, g, request, jsonify
from common import slack_post
page_api = Blueprint("api", __name__)
db = MongoClient(const.MONGO_URI).get_default_database()
def check_api_key(f):
@wraps(f)
def decorated_function(*args, **kwargs):
request_key = request.headers.get('Authorization', False)
if not request_key:
return "nope", 403
found_key = re.search(r"Basic (.*)", request_key).group(1)
if not found_key or found_key != const.INVENTORY_API_KEY:
return "nope", 403
return f(*args, **kwargs)
return decorated_function
@page_api.route("/cards", methods=["POST"])
@check_api_key
def get_group_cards():
request_groups = request.json.get("groups", False)
if not request_groups:
return "must specify groups in parameter", 400
print(f"found {len(g.users)} users for groups: {request_groups}")
keys = []
for u in g.users:
for group in u.groups:
if group in request_groups:
keys.append(u.username)
break
print(f"{len(keys)} doorkeys")
flt = {
"token.uid_hash": {"$exists": True},
"inventory.owner.username": {"$in": keys}
}
prj = {
"inventory.owner": True,
"token.uid_hash": True
}
found = []
for obj in db.inventory.find(flt, prj):
found.append({"token": obj["token"]})
fl = list(found)
print(f"{len(fl)} doorkey tokens")
return jsonify(fl)
@page_api.route("/api/slack/doorboy", methods=['POST'])
def view_slack_doorboy():
begin_time = time.perf_counter()
if request.form.get("token") != const.SLACK_VERIFICATION_TOKEN:
return "Invalid token was supplied"
if request.form.get("channel_id") not in ("C01CWPF5H8W", "CDL9H8Q9W"):
return "Invalid channel was supplied"
command = request.form.get("command")
try:
door = {
"/open-all-doors": "outsidedoors",
"/open-back-door": "backdoor",
"/open-front-door": "frontdoor",
"/open-ground-door": "grounddoor",
"/open-workshop-door": "workshopdoor"
}[command]
except KeyError:
return "Invalid command was supplied"
member = None
for user in g.users:
if user.slack_id == request.form.get("user_id"):
member = user
if door == "workshopdoor":
access_group = "k-space:workshop"
else:
access_group = "k-space:floor"
approved = access_group in member.groups
doors = [door]
if door == "outsidedoors":
doors = ["backdoor", "frontdoor", "grounddoor"]
status = "Permitted" if approved else "Denied"
subject = member.display_name
threading.Thread(target=handle_slack_door_event, args=(doors, approved, member, door, status, subject)).start()
return_message = "Opening %s for %s" % (door, subject) if approved else "Permission denied"
end_time = time.perf_counter()
print(f"view_slack_doorboy done in {end_time - begin_time:.4f} seconds")
return return_message
def handle_slack_door_event(doors, approved, member, door, status, subject):
begin_time = time.perf_counter()
for d in doors:
db.eventlog.insert_one({
"method": "slack",
"approved": approved,
"duration": 5,
"component": "doorboy",
"type": "open-door",
"door": d,
"member_id": member.username,
"member": member.display_name,
"timestamp": datetime.utcnow(),
})
msg = "%s %s door access for %s via Slack bot" % (status, door, subject)
slack_post(msg, "doorboy")
end_time = time.perf_counter()
print(f"handle_slack_door_event done in {end_time - begin_time:.4f} seconds")

View File

@@ -1,16 +1,17 @@
import collections.abc
import os
from dataclasses import dataclass, field
import collections.abc
from functools import wraps
from typing import List
import const
import requests
from bson.objectid import ObjectId
from common import read_user
from flask import g, request
from flask_wtf import FlaskForm
from kubernetes import client, config
from pymongo import MongoClient
from kubernetes import client, config
from typing import List
from dataclasses import dataclass, field
import const
devenv = const.ENVIRONMENT_TYPE == "DEV"
OIDC_USERS_NAMESPACE = os.getenv("OIDC_USERS_NAMESPACE")
@@ -92,6 +93,18 @@ def flatten(d, parent_key='', sep='.'):
items.append((new_key, v))
return dict(items)
def slack_post(msg, channel):
if devenv:
print(f"{channel}: {msg}")
return
channels = {
"doorboy": const.SLACK_DOORLOG_CALLBACK,
}
url = channels.get(channel, const.SLACK_DOORLOG_CALLBACK)
requests.post(url, json={"text": msg })
def build_query(base_query, fields=[], sort_fields={}):
top_usernames= ['k-space']
selectors = []

View File

@@ -12,8 +12,12 @@ def file_exists(path):
ENVIRONMENT_TYPE = getenv_in("ENVIRONMENT_TYPE", "DEV", "PROD")
SECRET_KEY = os.environ["SECRET_KEY"]
AWS_S3_ENDPOINT_URL = os.environ["AWS_S3_ENDPOINT_URL"]
BUCKET_NAME = os.environ["BUCKET_NAME"]
INVENTORY_ASSETS_BASE_URL = os.environ["INVENTORY_ASSETS_BASE_URL"]
MONGO_URI = os.environ["MONGO_URI"]
SLACK_VERIFICATION_TOKEN = os.environ["SLACK_VERIFICATION_TOKEN"] # used to verify (deprecated) incoming requests from slack
SLACK_DOORLOG_CALLBACK = os.environ["SLACK_DOORLOG_CALLBACK"] # used for sending logs to private channel
INVENTORY_API_KEY = os.environ["INVENTORY_API_KEY"] # used by doorboy-proxy (@check_api_key)
MACADDRESS_OUTLINK_BASEURL = os.environ["MACADDRESS_OUTLINK_BASEURL"]

View File

@@ -1,18 +1,19 @@
from datetime import datetime, timedelta
from dateutil.parser import parse, ParserError
import const
import pytz
from bson.objectid import ObjectId
from common import User
from dateutil.parser import ParserError, parse
from flask import Blueprint, abort, g, redirect, render_template, request
from flask import Blueprint, g, redirect, render_template, request, abort
from flask_wtf import FlaskForm
from oidc import login_required, read_user
from pymongo import MongoClient
from wtforms import (BooleanField, IntegerField, SelectField, StringField,
validators)
from wtforms import StringField, IntegerField, SelectField, BooleanField, DateTimeField, validators
from wtforms.validators import DataRequired
import pytz
import const
from api import check_api_key
from common import slack_post, User
from oidc import login_required, read_user
page_doorboy = Blueprint("doorboy", __name__)
db = MongoClient(const.MONGO_URI).get_default_database()
@@ -27,7 +28,7 @@ def view_doorboy_claim(event_id):
})
# Find token object to associate with user
db.inventory.update_one({
token = db.inventory.update_one({
"type": "token",
"token.uid_hash": event["token"]["uid_hash"],
"inventory.owner.username": { "$exists": False }
@@ -127,27 +128,23 @@ class HoldDoorForm(FlaskForm):
door_name = SelectField("Door name", choices=[(j,j) for j in ["grounddoor", "frontdoor", "backdoor"]], validators=[DataRequired()])
duration = IntegerField('Duration in seconds', validators=[DataRequired(), validators.NumberRange(min=5, max=21600)])
# duration=0 to override and close right away
@page_doorboy.route("/m/doorboy/hold", methods=["POST"])
@login_required
def view_doorboy_hold():
user = read_user()
form = HoldDoorForm(request.form)
now = datetime.utcnow()
if form.validate_on_submit():
db.eventlog.insert_one({
"component": "doorboy",
"method": "hold",
"timestamp": datetime.utcnow(),
"type": "hold",
"requester": user["name"],
"door": form.door_name.data,
"approved": True,
"user": {
"id": user["username"],
"name": user["name"]
},
"expires": datetime.utcnow() + timedelta(seconds=form.duration.data)
})
return redirect("/m/doorboy")
@page_doorboy.route("/m/doorboy/<door>/open")
@login_required
def view_doorboy_open(door):
@@ -161,22 +158,42 @@ def view_doorboy_open(door):
access_group = "k-space:floor"
approved = access_group in g.users_lookup.get(user["username"], User()).groups
db.eventlog.insert_one({
"component": "doorboy",
"method": "web",
"timestamp": datetime.utcnow(),
"door": door,
"approved": approved,
"user": {
"id": user["username"],
"name": user["name"]
}
"duration": 5,
"component": "doorboy",
"type": "open-door",
"door": door,
"member_id": user["username"],
"member": user["name"],
"timestamp": datetime.utcnow(),
})
status = "Permitted" if approved else "Denied"
subject = user["name"]
msg = "%s %s door access for %s via https://inventory.k-space.ee/m/doorboy" % (status, door, subject)
slack_post(msg, "doorboy")
if approved:
return redirect("/m/doorboy")
else:
return "", 401
@page_doorboy.route("/m/doorboy/slam", methods=["POST"])
@login_required
def view_doorboy_slam():
user = read_user()
db.eventlog.insert_one({
"component": "doorboy",
"type": "hold",
"requester": user["name"],
"door": form.door_name.data,
"expires": datetime.utcnow() + timedelta(minutes=form.duration_min.data)
})
return redirect("/m/doorboy")
@page_doorboy.route("/m/doorboy")
@login_required
def view_doorboy():
@@ -278,3 +295,73 @@ def view_doorboy_token_events(token_id):
token = db.inventory.find_one({"_id": ObjectId(token_id)})
latest_events = db.eventlog.find({"component": "doorboy", "event":"card-swiped", "token.uid_hash": token.get("token").get("uid_hash")}).sort([("timestamp", -1)])
return render_template("doorboy.html", **locals())
class FormSwipe(FlaskForm):
class Meta:
csrf = False
uid = StringField('uid', validators=[])
uid_hash = StringField('uid', validators=[])
door = StringField('door', validators=[DataRequired()])
success = BooleanField('success', validators=[])
timestamp = DateTimeField('timestamp')
@page_doorboy.route("/m/doorboy/swipe", methods=["POST"])
@check_api_key
def view_swipe():
form = request.json
print(form)
timestamp = parse(form["timestamp"]) if form.get("timestamp") else None
now = datetime.utcnow()
# Make sure token exists
db.inventory.update_one({
"type": "token",
"component": "doorboy",
"token.uid_hash": form["uid_hash"]
}, {
"$set": {
"last_seen": timestamp or now
},
"$setOnInsert": {
"component": "doorboy",
"type": "token",
"first_seen": now,
"inventory": {
"claimable": True,
}
}
}, upsert=True)
# Fetch token to read owner
token = db.inventory.find_one({
"type": "token",
"component": "doorboy",
"token.uid_hash": form["uid_hash"]
})
event_swipe = {
"component": "doorboy",
"timestamp": timestamp,
"door": form["door"],
"event": "card-swiped",
"success": form["success"],
"token": {
"uid_hash": form["uid_hash"]
},
"inventory": {}
}
if token.get("inventory", {}).get("owner", {}).get("username", None):
event_swipe["inventory"]["owner_id"] = token["inventory"]["owner"]["username"]
db.eventlog.insert_one(event_swipe)
status = "Permitted" if form["success"] else "Denied"
username = token.get("inventory", {}).get("owner", {}).get("username", None)
if username and username in g.users_lookup:
subject = g.users_lookup[username].display_name or username
else:
subject = "Unknown"
msg = "%s %s door access for %s identified by keycard/keyfob" % (status, form["door"], subject)
slack_post(msg, "doorboy")
return "ok"

View File

@@ -216,6 +216,8 @@ def save_inventory_item(item_id=None, **_):
d = {}
form.populate_dict(d)
d['tags'] = list(set(request.form.getlist('tags[]')))
if d.get('location'):
d['location_code'] = render_location_link(d['location'])
custom_errors = {}
try:
if item_id:
@@ -256,6 +258,16 @@ def save_inventory_item(item_id=None, **_):
return render_template("inventory_edit.html", **locals())
return redirect("/m/inventory/%s/view" % item_id)
def render_location_link(location: str):
if location == "":
return
linkstart = location.find("k6.ee/")
if linkstart == -1:
return
return location[linkstart:].split(" ", 1)[0].split("/", 1)[1]
def is_image_ext(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ["jpg", "jpeg"]
@@ -598,3 +610,18 @@ def view_inventory_vacate(item_id):
},
})
return redirect("/m/inventory/%s/view" % item_id)
@page_inventory.route("/m/inventory/contains", methods=["GET"])
@login_required(groups=["k-space:inventory:audit"])
def get_contains():
slug = request.args.get("slug")
if not slug:
return []
return list(db.inventory.find({
"location_code": slug
}, {
"_id": 0,
"shortener.slug": 1,
"name": 1
}))

View File

@@ -25,8 +25,9 @@ from wtforms import (
import const
from common import devenv, format_name, get_users, User
from inventory import page_inventory
from oidc import page_oidc, login_required
from oidc import page_oidc, login_required, read_user
from doorboy import page_doorboy
from api import page_api
def check_foreign_key_format(item):
owner = item.get("inventory", {}).get("owner", {})
@@ -123,9 +124,12 @@ app = Flask(__name__)
app.wsgi_app = ReverseProxied(app.wsgi_app)
app.register_blueprint(page_inventory)
app.register_blueprint(page_oidc)
app.register_blueprint(page_api)
app.register_blueprint(page_doorboy)
metrics = PrometheusMetrics(app, group_by="path")
app.config['SECRET_KEY'] = const.SECRET_KEY
mongoclient = MongoClient(const.MONGO_URI)
mongodb = mongoclient.get_default_database()
mongodb.member.create_index("ad.username", sparse=True, unique=True)

View File

@@ -84,7 +84,7 @@ Does not include door opens by webhook.
<td>{{ o.door }}</td>
<td>{% if o.inventory and o.inventory.owner %}<a href="/m/user/{{ o.inventory.owner.username }}">{{ o.inventory.owner.username | display_name }}</a>{% else %}Unknown{% endif %}</td>
<td><a href="/m/doorboy/{{ o._id }}/events">{{ o.token.uid_hash[-6:] }}</a></td>
<!-- <td>{% if o.approved %}<i class="material-icons">check_circle</i>{% else %}&nbsp;{% endif %}</td> -->
<!-- <td>{% if o.success %}<i class="material-icons">check_circle</i>{% else %}&nbsp;{% endif %}</td> -->
<td>{% if o.inventory and o.inventory.owner %}{{ o.token.comment }}{% else %}<a class="waves-effect waves-light btn" href="/m/doorboy/{{ o._id }}/claim">Claim keycard</a>{% endif %}</td>
</tr>
{% endfor %}

View File

@@ -136,6 +136,15 @@
{% endif %}
</div>
<div class="browser-default" id="contains">
<h3>Contains</h3>
<ul class="browser-default">
<li class="closed browser-default" data-name="{{ item.name }}" data-slug="{{ item.get("shortener", {}).get("slug") }}">
This item
</li>
</ul>
</div>
</div>
<script>
@@ -170,6 +179,82 @@ $(function() {
}
});
{% endif %}
function slugLink(slug) {
if (!slug) {
return "";
}
var href = "k6.ee/" + slug;
return $("<a/>").attr("href", "https://" + href).text(" " + href);
}
function expandLocation(e) {
e.stopPropagation();
var cur = $(this);
var slug = cur.data("slug");
var name = cur.data("name");
if (cur.hasClass("empty")) {
return;
}
if (!cur.hasClass("closed")) {
cur.empty();
cur.text(name);
cur.append(slugLink(slug));
cur.addClass("closed");
return;
}
var expand = $("<ul/>").data("slug", slug);
expand.attr("class", "browser-default");
$.get("/m/inventory/contains", { slug }, function(data) {
cur.removeClass("closed");
if (!data.length) {
cur.addClass("empty");
return;
}
$.each(data, function(k, v) {
var inner = $("<li/>").data("slug", v.shortener.slug);
inner.text(v.name);
inner.data("name", v.name);
inner.on("click", expandLocation);
inner.attr("class", "closed browser-default");
inner.append(slugLink(v.shortener.slug));
expand.append(inner);
});
cur.html(expand);
cur.prepend(slugLink(slug));
cur.prepend(name);
});
}
var contains = $("div#contains li");
contains.on("click", expandLocation);
});
</script>
<style>
div#contains {
padding-bottom: 6em;
}
div#contains ul {
list-style: none;
padding: 0;
margin: 0;
}
div#contains li {
padding-left: 16px;
}
div#contains li::before {
content: "▼";
padding-right: 8px;
}
div#contains li.closed::before {
content: "►";
}
div#contains li.empty::before {
content: "";
}
</style>
{% endblock %}