Compare commits

1 Commits

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

View File

@@ -4,17 +4,12 @@ RUN apt-get update \
curl ca-certificates iputils-ping \ curl ca-certificates iputils-ping \
python3-ldap python3-pip python3-ldap \ python3-ldap python3-pip python3-ldap \
libjpeg-dev libturbojpeg0-dev \ libjpeg-dev libturbojpeg0-dev \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/* \
&& apt-get clean
COPY requirements.txt ./ COPY requirements.txt ./
#necessary hack for cffi #necessary hack for cffi
RUN pip3 install cffi RUN pip3 install cffi
RUN pip3 install -r requirements.txt RUN pip3 install -r requirements.txt
COPY inventory-app /app
ENV PYTHONUNBUFFERED=1
ENV ENVIRONMENT_TYPE=PROD
WORKDIR /app WORKDIR /app
COPY inventory-app . ENTRYPOINT /app/main.py
ENTRYPOINT ["python3", "/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: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:edit|Edit all non-key items. Browse items with Protected visibility.|
|k-space:inventory:keys|Edit keys| |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 apiVersion: apps/v1
kind: Deployment kind: Deployment
@@ -24,11 +23,33 @@ spec:
env: env:
- name: OIDC_USERS_NAMESPACE - name: OIDC_USERS_NAMESPACE
value: "default" 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
- name: RECAPTCHA_PRIVATE_KEY
value: 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe
- name: INVENTORY_ASSETS_BASE_URL
value: "https://minio.codemowers.eu:9000"
- name: MONGO_URI - name: MONGO_URI
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: mongodb-application-readwrite name: mongodb-application-readwrite
key: connectionString.standard key: connectionString.standard
- name: AWS_ENDPOINT_URL
valueFrom:
secretKeyRef:
name: miniobucket-inventory-app-owner-secrets
key: MINIO_URI
- name: SECRET_KEY
value: "bad_secret"
- name: ENVIRONMENT_TYPE - name: ENVIRONMENT_TYPE
value: "DEV" value: "DEV"
- name: MY_POD_NAME - 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 import os
from dataclasses import dataclass, field import collections.abc
from functools import wraps from functools import wraps
from typing import List
import const import requests
from bson.objectid import ObjectId from bson.objectid import ObjectId
from flask import g, request from flask import g, request
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from kubernetes import client, config
from oidc import read_user
from pymongo import MongoClient 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" devenv = const.ENVIRONMENT_TYPE == "DEV"
OIDC_USERS_NAMESPACE = os.getenv("OIDC_USERS_NAMESPACE") OIDC_USERS_NAMESPACE = os.getenv("OIDC_USERS_NAMESPACE")
@@ -92,6 +93,18 @@ def flatten(d, parent_key='', sep='.'):
items.append((new_key, v)) items.append((new_key, v))
return dict(items) 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={}): def build_query(base_query, fields=[], sort_fields={}):
top_usernames= ['k-space'] top_usernames= ['k-space']
selectors = [] selectors = []

View File

@@ -12,8 +12,12 @@ def file_exists(path):
ENVIRONMENT_TYPE = getenv_in("ENVIRONMENT_TYPE", "DEV", "PROD") ENVIRONMENT_TYPE = getenv_in("ENVIRONMENT_TYPE", "DEV", "PROD")
SECRET_KEY = os.environ["SECRET_KEY"]
AWS_S3_ENDPOINT_URL = os.environ["AWS_S3_ENDPOINT_URL"] AWS_S3_ENDPOINT_URL = os.environ["AWS_S3_ENDPOINT_URL"]
BUCKET_NAME = os.environ["BUCKET_NAME"] BUCKET_NAME = os.environ["BUCKET_NAME"]
INVENTORY_ASSETS_BASE_URL = os.environ["INVENTORY_ASSETS_BASE_URL"]
MONGO_URI = os.environ["MONGO_URI"] 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"] MACADDRESS_OUTLINK_BASEURL = os.environ["MACADDRESS_OUTLINK_BASEURL"]
COOKIES_SECRET_KEY = os.environ["COOKIES_SECRET_KEY"] # session storage, random chars

View File

@@ -1,15 +1,19 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from dateutil.parser import parse, ParserError
import const
from bson.objectid import ObjectId from bson.objectid import ObjectId
from common import User from flask import Blueprint, g, redirect, render_template, request, abort
from flask import Blueprint, abort, g, redirect, render_template, request
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from oidc import login_required, read_user
from pymongo import MongoClient 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 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__) page_doorboy = Blueprint("doorboy", __name__)
db = MongoClient(const.MONGO_URI).get_default_database() db = MongoClient(const.MONGO_URI).get_default_database()
@@ -24,7 +28,7 @@ def view_doorboy_claim(event_id):
}) })
# Find token object to associate with user # Find token object to associate with user
db.inventory.update_one({ token = db.inventory.update_one({
"type": "token", "type": "token",
"token.uid_hash": event["token"]["uid_hash"], "token.uid_hash": event["token"]["uid_hash"],
"inventory.owner.username": { "$exists": False } "inventory.owner.username": { "$exists": False }
@@ -38,6 +42,48 @@ def view_doorboy_claim(event_id):
return redirect("/m/doorboy") return redirect("/m/doorboy")
@page_doorboy.route("/m/doorboy/<token_id>/disable")
@login_required
def view_doorboy_disable(token_id):
user = read_user()
db.inventory.update_one({
"component": "doorboy",
"type": "token",
"_id": ObjectId(token_id),
"inventory.owner.username": user["username"]
}, {
"$set": {
"token.disabled": datetime.utcnow()
},
"$unset": {
"token.enabled": ""
}
})
return redirect("/m/doorboy")
@page_doorboy.route("/m/doorboy/<token_id>/enable")
@login_required
def view_doorboy_enable(token_id):
user = read_user()
db.inventory.update_one({
"component": "doorboy",
"type": "token",
"_id": ObjectId(token_id),
"inventory.owner.username": user["username"]
}, {
"$set": {
"token.enabled": datetime.utcnow(),
},
"$unset": {
"token.disabled": ""
}
})
return redirect("/m/doorboy")
class TokenEditForm(FlaskForm): class TokenEditForm(FlaskForm):
comment = StringField("Comment") comment = StringField("Comment")
enabled = BooleanField("Enabled") enabled = BooleanField("Enabled")
@@ -82,28 +128,23 @@ class HoldDoorForm(FlaskForm):
door_name = SelectField("Door name", choices=[(j,j) for j in ["grounddoor", "frontdoor", "backdoor"]], validators=[DataRequired()]) 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 = 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"]) @page_doorboy.route("/m/doorboy/hold", methods=["POST"])
@login_required @login_required
def view_doorboy_hold(): def view_doorboy_hold():
user = read_user() user = read_user()
form = HoldDoorForm(request.form) form = HoldDoorForm(request.form)
now = datetime.utcnow()
if form.validate_on_submit(): if form.validate_on_submit():
db.eventlog.insert_one({ db.eventlog.insert_one({
"component": "doorboy", "component": "doorboy",
"method": "hold", "type": "hold",
"timestamp": datetime.utcnow(), "requester": user["name"],
"door": form.door_name.data, "door": form.door_name.data,
"approved": True,
"user": {
"id": user["username"],
"name": user["name"]
},
"expires": datetime.utcnow() + timedelta(seconds=form.duration.data) "expires": datetime.utcnow() + timedelta(seconds=form.duration.data)
}) })
return redirect("/m/doorboy") return redirect("/m/doorboy")
# Writes open event to log, which is picked up by doorboy-proxy.
@page_doorboy.route("/m/doorboy/<door>/open") @page_doorboy.route("/m/doorboy/<door>/open")
@login_required @login_required
def view_doorboy_open(door): def view_doorboy_open(door):
@@ -117,31 +158,56 @@ def view_doorboy_open(door):
access_group = "k-space:floor" access_group = "k-space:floor"
approved = access_group in g.users_lookup.get(user["username"], User()).groups approved = access_group in g.users_lookup.get(user["username"], User()).groups
db.eventlog.insert_one({ db.eventlog.insert_one({
"component": "doorboy",
"method": "web", "method": "web",
"timestamp": datetime.utcnow(),
"door": door,
"approved": approved, "approved": approved,
"user": { "duration": 5,
"id": user["username"], "component": "doorboy",
"name": user["name"] "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: if approved:
return redirect("/m/doorboy") return redirect("/m/doorboy")
else: else:
return "", 401 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") @page_doorboy.route("/m/doorboy")
@login_required @login_required
def view_doorboy(): def view_doorboy():
user = read_user() user = read_user()
workshop_access = "k-space:workshop" in g.users_lookup.get(user["username"], User()).groups workshop_access = "k-space:workshop" in g.users_lookup.get(user["username"], User()).groups
# latest_events = db.eventlog.find({"component": "doorboy", "type":"open-door"}).sort([("timestamp", -1)]).limit(10);
latest_swipes = db.inventory.find({"component": "doorboy", "type":"token"}).sort([("last_seen", -1)]).limit(10) latest_swipes = db.inventory.find({"component": "doorboy", "type":"token"}).sort([("last_seen", -1)]).limit(10);
return render_template("doorboy.html", **locals()) return render_template("doorboy.html", **locals())
@page_doorboy.route("/m/doorboy/user/<username>/cards")
@login_required(groups=["k-space:board", "k-space:kubernetes:admins"])
def view_user_cards(username):
return view_user_cards_inner(username)
@page_doorboy.route("/m/doorboy/me") @page_doorboy.route("/m/doorboy/me")
@login_required @login_required
def view_own_cards(): def view_own_cards():
@@ -161,9 +227,141 @@ def view_user_cards_inner(username):
}).sort([("last_seen", -1)]) }).sort([("last_seen", -1)])
return render_template("doorboy_user.html", **locals()) return render_template("doorboy_user.html", **locals())
#TODO: only returns UID opens, not web or slack @page_doorboy.route("/m/doorboy/admin")
@page_doorboy.route("/m/doorboy/log/<username>")
@login_required(groups=["k-space:board", "k-space:kubernetes:admins"]) @login_required(groups=["k-space:board", "k-space:kubernetes:admins"])
def view_user_events(username): def view_doorboy_admin():
latest_events = db.eventlog.find({"component": "doorboy", "inventory.owner_id": username}).sort([("timestamp", -1)]) results = db.inventory.aggregate([
return render_template("doorboy_log.html", latest_events=latest_events) { "$match": {"component": "doorboy", "type": "token"} },
{
"$group": {
"_id": "$inventory.owner.username",
"cards": {
"$push" : {"$mergeObjects": [
"$token",
{"last_seen": "$last_seen"},
{"_id": "$_id"},
{"old_display_name": "$inventory.owner.display_name"},
{"old_foreign_id": "$inventory.owner.foreign_id"}
]}
}
}
},
{ "$sort": { "_id" : 1 } }
])
user_keyfobs = {r["_id"] : r["cards"] for r in results}
orphaned_keyfobs = user_keyfobs.pop(None)
no_keyfobs = [u for u in g.users if not user_keyfobs.get(u.username)]
no_keyfobs = list(filter(lambda u : u.user_type == "person", no_keyfobs))
last_seen = {key : max(datetime_handle(card.get("last_seen")) for card in value) for key, value in user_keyfobs.items()}
orphaned_keyfobs = sorted(orphaned_keyfobs, key = lambda o : (not bool(o.get("comment")), o.get("comment", "")))
no_keyfobs = sorted(no_keyfobs, key = lambda u : u.display_name or u.username)
last_seen = dict(sorted(last_seen.items(), key=lambda i : datetime_handle(i[1]), reverse=True))
return render_template("doorboy_admin.html", **locals())
def datetime_handle(item):
if not item:
dt = datetime.min
elif type(item) is str:
try:
dt = parse(item)
except ParserError as e:
print(e)
dt = datetime.min
elif type(item) is datetime:
dt = item
else:
dt = datetime.min
try:
dt = pytz.UTC.localize(dt)
except ValueError:
pass
return dt
@page_doorboy.route("/m/doorboy/swipes")
@login_required
def view_doorboy_events():
user = read_user()
latest_events = db.eventlog.find({"component": "doorboy", "event":"card-swiped"}).sort([("timestamp", -1)]).limit(500);
return render_template("doorboy.html", **locals())
@page_doorboy.route("/m/doorboy/<token_id>/events")
@login_required
def view_doorboy_token_events(token_id):
user = read_user()
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

@@ -1,22 +1,26 @@
import re import re
import urllib import boto3
from datetime import date, datetime, timedelta
import const
import inventory_image
import pymongo import pymongo
import urllib
from datetime import datetime, date, timedelta
from botocore.exceptions import ClientError
from bson.objectid import ObjectId from bson.objectid import ObjectId
from common import CustomForm, build_query, flatten
from flask import Blueprint, abort, g, redirect, render_template, request, url_for from flask import Blueprint, abort, g, redirect, render_template, request, url_for
from oidc import do_login, login_required, read_user from jpegtran import JPEGImage
from pymongo import MongoClient from pymongo import MongoClient
from werkzeug.utils import secure_filename
from wtforms import BooleanField, SelectField, StringField from wtforms import BooleanField, SelectField, StringField
from wtforms.fields import FormField from wtforms.fields import FormField
from wtforms.form import Form from wtforms.form import Form
from wtforms.validators import Length from wtforms.validators import Length
import const
from common import CustomForm, build_query, flatten
from oidc import login_required, read_user, do_login
page_inventory = Blueprint("inventory", __name__) page_inventory = Blueprint("inventory", __name__)
db = MongoClient(const.MONGO_URI).get_default_database() db = MongoClient(const.MONGO_URI).get_default_database()
channel = "inventory"
@login_required @login_required
@page_inventory.route("/m/inventory/by-mac/<mac>", methods=['GET']) @page_inventory.route("/m/inventory/by-mac/<mac>", methods=['GET'])
@@ -46,11 +50,25 @@ def view_inventory_view(item_id):
can_audit = "k-space:inventory:audit" in user.get("groups", []) can_audit = "k-space:inventory:audit" in user.get("groups", [])
can_edit = check_edit_permission(item_id) can_edit = check_edit_permission(item_id)
is_using = item_user and item_user == user["username"] is_using = item_user and item_user == user["username"]
photo_url = inventory_image.get_image_url(item_id) photo_url = get_image_url(item_id)
# pylance: disable=unused-variable # pylance: disable=unused-variable
constants = {"MACADDRESS_OUTLINK_BASEURL": const.MACADDRESS_OUTLINK_BASEURL} constants = {"MACADDRESS_OUTLINK_BASEURL": const.MACADDRESS_OUTLINK_BASEURL}
return render_template(template , **locals()) return render_template(template , **locals())
def get_image_url(item_id):
bucket=get_bucket()
try:
return bucket.generate_presigned_url(
ClientMethod='get_object',
Params={
'Bucket': const.BUCKET_NAME,
'Key': item_id
},
ExpiresIn=3600
)
except ClientError:
return None
def fetch_members_select(): def fetch_members_select():
top_usernames= ['k-space', None] top_usernames= ['k-space', None]
choices = [(None, None)] choices = [(None, None)]
@@ -198,6 +216,8 @@ def save_inventory_item(item_id=None, **_):
d = {} d = {}
form.populate_dict(d) form.populate_dict(d)
d['tags'] = list(set(request.form.getlist('tags[]'))) d['tags'] = list(set(request.form.getlist('tags[]')))
if d.get('location'):
d['location_code'] = render_location_link(d['location'])
custom_errors = {} custom_errors = {}
try: try:
if item_id: if item_id:
@@ -238,6 +258,138 @@ def save_inventory_item(item_id=None, **_):
return render_template("inventory_edit.html", **locals()) return render_template("inventory_edit.html", **locals())
return redirect("/m/inventory/%s/view" % item_id) 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"]
def get_bucket():
return boto3.client('s3',
endpoint_url=const.AWS_S3_ENDPOINT_URL,
config=boto3.session.Config(signature_version='s3v4'),
region_name='us-east-1')
@page_inventory.route("/inventory/<item_id>/upload-photo", methods=["POST"])
@login_required
def upload_photo(item_id):
user = read_user()
item = db.inventory.find_one(filter = { "_id": ObjectId(item_id) }, projection = { "thumbs": 1 })
if not item:
return "Item not found", 404
if item.get("type") == "key" and "k-space:inventory:keys" not in user.get("groups", []):
return abort(403)
if "file" not in request.files:
return "No file part", 400
file = request.files["file"]
if not file.filename:
return "No selected file", 400
if file and is_image_ext(secure_filename(file.filename)):
try:
file.seek(0)
img = JPEGImage(blob=file.read())
except:
return "Not a valid JPEG", 400
if min(img.width, img.height) < 576:
return "Image must have smallest dimension of at least 576px", 400
bucket = get_bucket()
file.seek(0)
bucket.upload_fileobj(file, const.BUCKET_NAME, item_id)
db.inventory.update_one({ "_id": ObjectId(item_id) }, {"$set": {"has_photo": True}})
delete_thumbs(item)
return redirect("/m/inventory/%s/view" % item_id)
else:
return "File is not valid", 400
@page_inventory.app_template_filter('thumbnail')
def thumbnail_filter(item_id, dimension):
return get_scaled_photo(item_id, dimension)
@page_inventory.route("/m/photo/<item_id>/<dimension>")
def get_scaled_photo(item_id=None, dimension=0, retry=2):
dimension = int(dimension) #why
if retry <= 0:
return "", 500
if dimension not in [240, 480, 576, 600]:
return abort(404)
item = db.inventory.find_one(filter = { "_id": ObjectId(item_id) }, projection = { "thumbs": 1 })
if not item:
return abort(404)
if item.get("thumbs", {}).get(str(dimension)):
thumb = item["thumbs"][str(dimension)]
img_url = get_image_url(thumb["name"])
if not img_url:
delete_thumbs(item)
return get_scaled_photo(item_id, dimension, retry - 1)
else:
make_thumb(item, dimension)
return get_scaled_photo(item_id, dimension, retry - 1)
return img_url
def get_item(key):
try:
bucket = get_bucket()
r = bucket.get_object(Bucket=const.BUCKET_NAME, Key=key)
return r['Body'].read()
except:
return None
def make_thumb(item, dimension):
img = get_item(str(item["_id"]))
img = scale_image(img, dimension)
bucket = get_bucket()
thumb_name = "thumb_%s_%d" % (item["_id"], dimension)
bucket.put_object(Body=img.as_blob(), Bucket=const.BUCKET_NAME, Key=thumb_name)
db.inventory.update_one({
"_id": ObjectId(item["_id"]),
}, {
"$set": {
"thumbs.%d.name" % dimension : thumb_name,
},
})
return img
def scale_image(img, dimension):
img = JPEGImage(blob=img)
if img.exif_orientation:
img = img.exif_autotransform()
if img.width < img.height:
img = img.downscale(dimension, img.height * dimension // img.width, 100)
else:
img = img.downscale(img.width * dimension // img.height, dimension, 100)
ratio = 4 / 3
crop_height = min(img.width / ratio, img.height)
crop_width = crop_height * ratio
crop_x = int((img.width - crop_width) / 2) & ~7
crop_y = int((img.height - crop_height) / 2) & ~7
return img.crop(crop_x, crop_y, int(crop_width), int(crop_height))
def delete_thumbs(item):
bucket = get_bucket()
for _, thumb in item.get("thumbs", {}).items():
if thumb.get("name"):
bucket.delete_object(Bucket=const.BUCKET_NAME, Key=thumb["name"])
db.inventory.update_one({
"_id": ObjectId(item["_id"])
}, {
"$unset": {
"thumbs": ""
},
})
@page_inventory.route("/m/inventory/assign-mac/<slug>") @page_inventory.route("/m/inventory/assign-mac/<slug>")
@page_inventory.route("/m/inventory/assign-slug/<slug>") @page_inventory.route("/m/inventory/assign-slug/<slug>")
@@ -458,3 +610,18 @@ def view_inventory_vacate(item_id):
}, },
}) })
return redirect("/m/inventory/%s/view" % 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

@@ -1,151 +0,0 @@
import boto3
import const
from botocore.exceptions import ClientError
from bson.objectid import ObjectId
from flask import Blueprint, abort, redirect, request
from jpegtran import JPEGImage
from oidc import login_required, read_user
from pymongo import MongoClient
from werkzeug.utils import secure_filename
page_inventory_image = Blueprint("inventory_image", __name__)
db = MongoClient(const.MONGO_URI).get_default_database()
def is_image_ext(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ["jpg", "jpeg"]
# AWS S3 credentials / env is automagically imported https://boto3.amazonaws.com/v1/documentation/api/latest/guide/configuration.html#using-environment-variables
# AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION, etc
def get_bucket(): #TODO
return boto3.client('s3',
endpoint_url=const.AWS_S3_ENDPOINT_URL,
config=boto3.session.Config(signature_version='s3v4'))
@page_inventory_image.route("/inventory/<item_id>/upload-photo", methods=["POST"])
@login_required
def upload_photo(item_id):
user = read_user()
item = db.inventory.find_one(filter = { "_id": ObjectId(item_id) }, projection = { "thumbs": 1 })
if not item:
return "Item not found", 404
if item.get("type") == "key" and "k-space:inventory:keys" not in user.get("groups", []):
return abort(403)
if "file" not in request.files:
return "No file part", 400
file = request.files["file"]
if not file.filename:
return "No selected file", 400
if file and is_image_ext(secure_filename(file.filename)):
try:
file.seek(0)
img = JPEGImage(blob=file.read())
except:
return "Not a valid JPEG", 400
if min(img.width, img.height) < 576:
return "Image must have smallest dimension of at least 576px", 400
bucket = get_bucket()
file.seek(0)
bucket.upload_fileobj(file, const.BUCKET_NAME, item_id)
db.inventory.update_one({ "_id": ObjectId(item_id) }, {"$set": {"has_photo": True}})
delete_thumbs(item)
return redirect("/m/inventory/%s/view" % item_id)
else:
return "File is not valid", 400
@page_inventory_image.app_template_filter('thumbnail')
def thumbnail_filter(item_id, dimension):
return get_scaled_photo(item_id, dimension)
@page_inventory_image.route("/m/photo/<item_id>/<dimension>")
def get_scaled_photo(item_id=None, dimension=0, retry=2):
dimension = int(dimension) #why
if retry <= 0:
return "", 500
if dimension not in [240, 480, 576, 600]:
return abort(404)
item = db.inventory.find_one(filter = { "_id": ObjectId(item_id) }, projection = { "thumbs": 1 })
if not item:
return abort(404)
if item.get("thumbs", {}).get(str(dimension)):
thumb = item["thumbs"][str(dimension)]
img_url = get_image_url(thumb["name"])
if not img_url:
delete_thumbs(item)
return get_scaled_photo(item_id, dimension, retry - 1)
else:
make_thumb(item, dimension)
return get_scaled_photo(item_id, dimension, retry - 1)
return img_url
def get_item(key):
try:
bucket = get_bucket()
r = bucket.get_object(Bucket=const.BUCKET_NAME, Key=key)
return r['Body'].read()
except:
return None
def make_thumb(item, dimension):
img = get_item(str(item["_id"]))
img = scale_image(img, dimension)
bucket = get_bucket()
thumb_name = "thumb_%s_%d" % (item["_id"], dimension)
bucket.put_object(Body=img.as_blob(), Bucket=const.BUCKET_NAME, Key=thumb_name)
db.inventory.update_one({
"_id": ObjectId(item["_id"]),
}, {
"$set": {
"thumbs.%d.name" % dimension : thumb_name,
},
})
return img
def scale_image(img, dimension):
img = JPEGImage(blob=img)
if img.exif_orientation:
img = img.exif_autotransform()
if img.width < img.height:
img = img.downscale(dimension, img.height * dimension // img.width, 100)
else:
img = img.downscale(img.width * dimension // img.height, dimension, 100)
ratio = 4 / 3
crop_height = min(img.width / ratio, img.height)
crop_width = crop_height * ratio
crop_x = int((img.width - crop_width) / 2) & ~7
crop_y = int((img.height - crop_height) / 2) & ~7
return img.crop(crop_x, crop_y, int(crop_width), int(crop_height))
def delete_thumbs(item):
bucket = get_bucket()
for _, thumb in item.get("thumbs", {}).items():
if thumb.get("name"):
bucket.delete_object(Bucket=const.BUCKET_NAME, Key=thumb["name"])
db.inventory.update_one({
"_id": ObjectId(item["_id"])
}, {
"$unset": {
"thumbs": ""
},
})
def get_image_url(item_id):
bucket=get_bucket()
try:
return bucket.generate_presigned_url(
ClientMethod='get_object',
Params={
'Bucket': const.BUCKET_NAME,
'Key': item_id
},
ExpiresIn=3600
)
except ClientError:
return None

View File

@@ -7,23 +7,27 @@ from functools import wraps
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
import bleach import bleach
import const
import jinja2 import jinja2
import markdown import markdown
import segno import segno
from common import User, devenv, format_name, get_users
from doorboy import page_doorboy
from flask import Flask, abort, g, redirect, request from flask import Flask, abort, g, redirect, request
from inventory import page_inventory
from inventory_image import page_inventory_image
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from markupsafe import Markup from markupsafe import Markup
from oidc import login_required, page_oidc from prometheus_client import Gauge, CollectorRegistry, generate_latest
from prometheus_client import CollectorRegistry, Gauge, generate_latest
from prometheus_flask_exporter import PrometheusMetrics from prometheus_flask_exporter import PrometheusMetrics
from pymongo import MongoClient from pymongo import MongoClient
from wtforms import SelectMultipleField, ValidationError, widgets from wtforms import (
SelectMultipleField,
ValidationError,
widgets,
)
import const
from common import devenv, format_name, get_users, User
from inventory import page_inventory
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): def check_foreign_key_format(item):
owner = item.get("inventory", {}).get("owner", {}) owner = item.get("inventory", {}).get("owner", {})
@@ -117,16 +121,18 @@ class ReverseProxied(object):
return self.app(environ, start_response) return self.app(environ, start_response)
app = Flask(__name__) app = Flask(__name__)
app.secret_key = const.COOKIES_SECRET_KEY
app.wsgi_app = ReverseProxied(app.wsgi_app) app.wsgi_app = ReverseProxied(app.wsgi_app)
app.register_blueprint(page_inventory) app.register_blueprint(page_inventory)
app.register_blueprint(page_inventory_image)
app.register_blueprint(page_oidc) app.register_blueprint(page_oidc)
app.register_blueprint(page_api)
app.register_blueprint(page_doorboy) app.register_blueprint(page_doorboy)
metrics = PrometheusMetrics(app, group_by="path") metrics = PrometheusMetrics(app, group_by="path")
app.config['SECRET_KEY'] = const.SECRET_KEY
mongoclient = MongoClient(const.MONGO_URI) mongoclient = MongoClient(const.MONGO_URI)
mongodb = mongoclient.get_default_database() mongodb = mongoclient.get_default_database()
mongodb.member.create_index("ad.username", sparse=True, unique=True)
mongodb.inventory.create_index("shortener.slug", sparse=True, unique=True) mongodb.inventory.create_index("shortener.slug", sparse=True, unique=True)
mongodb.inventory.create_index("token.uid_hash", sparse=True, unique=True) mongodb.inventory.create_index("token.uid_hash", sparse=True, unique=True)
#mongodb.inventory.create_index("token.uid_hash", unique=True) #mongodb.inventory.create_index("token.uid_hash", unique=True)
@@ -178,7 +184,7 @@ def do_before_request():
@app.context_processor @app.context_processor
def inject_context(): def inject_context():
return dict(devenv=devenv) return dict(devenv=devenv, inventory_assets_base_url=const.INVENTORY_ASSETS_BASE_URL)
def name_check(form, field): def name_check(form, field):
if field.data != field.data.strip(): if field.data != field.data.strip():

View File

@@ -84,7 +84,7 @@ Does not include door opens by webhook.
<td>{{ o.door }}</td> <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>{% 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><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> <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> </tr>
{% endfor %} {% endfor %}

View File

@@ -0,0 +1,82 @@
{% extends 'base.html' %}
{% block content %}
<ul class="collapsible expandable">
<li class="">
<div class="collapsible-header">
<i class="material-icons">access_time</i>Last seen
<div style="margin-left: auto;">
{{last_seen | length}}
</div>
</div>
<ul class="collapsible-body collection collapsible-collection">
{% for u, t in last_seen.items() %}
<li class="collection-item">
<a href="/m/doorboy/user/{{u}}/cards">
<i class="material-icons tiny">person</i>
{{u | display_name}}
</a>
<div class="secondary-content black-text">
<i class="material-icons tiny">access_time</i>
{{t | timeago}}
</div>
</li>
{% endfor %}
</ul>
</li>
<li>
<div class="red lighten-3 collapsible-header">
<i class="material-icons">error_outline</i>No keyfobs enrolled
<div style="margin-left: auto;">
{{no_keyfobs | length}}
</div>
</div>
<div class="collapsible-body collection collapsible-collection">
{% for u in no_keyfobs %}
<div class="collection-item">{{u.display_name or u.username}}</div>
{% endfor %}
</div>
</li>
<li>
<div class="red lighten-3 collapsible-header">
<i class="material-icons">error</i>Orphaned keyfobs
<div style="margin-left: auto;">
{{orphaned_keyfobs | length}}
</div>
</div>
<div class="collapsible-body collapsible-collection">
<table>
<thead>
<tr>
<th>Comment</th>
<th>Hash tail</th>
<th>Old ownership info</th>
</tr>
</thead>
<tbody>
{% for c in orphaned_keyfobs %}
<tr>
<td>{{c.comment or "unnamed"}}</td>
<td><a href="/m/doorboy/{{ c._id }}/events">{{ c.uid_hash[-6:] }}</a></td>
<td>
{{c.old_display_name}}
{% if c.old_foreign_id %}
({{c.old_foreign_id}})
{% endif %}
</td>
</tr>
{% endfor %}
<tbody>
</table>
</div>
</li>
</ul>
<style>
.collapsible-collection {
padding: 0;
}
</style>
{% endblock %}

View File

@@ -1,29 +0,0 @@
{% extends 'base.html' %}
{% block content %}
<h3>This page only shows UID opens!</h3>
<p>Does not include Slack opens, Web opens (via Doorboy/Inventory). Formats need to be unified. <b>Use #door-log Slack channel Ctrl-f instead!</b></p>
<table>
<thead>
<tr>
<th>Approved</th>
<th>Timestamp</th>
<th>Door</th>
<th>Who (UID hash)</th>
</tr>
</thead>
<tbody>
{% for o in latest_events %}
<tr>
<td>{% if o.approved %}<i class="material-icons">check_circle</i>{% else %}no{% endif %}</td>
<td>{{ o.timestamp | timeago }}</td>
<td>{{ o.door }}</td>
<td>{{ o.inventory.owner_id }} (<a href="/m/doorboy/{{ o._id }}/events">{{ o.token.uid_hash[-6:] }})</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -136,6 +136,15 @@
{% endif %} {% endif %}
</div> </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> </div>
<script> <script>
@@ -170,6 +179,82 @@ $(function() {
} }
}); });
{% endif %} {% 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> </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 %} {% endblock %}

8
minio.yml Normal file
View File

@@ -0,0 +1,8 @@
---
apiVersion: codemowers.cloud/v1beta1
kind: MinioBucketClaim
metadata:
name: inventory-app
spec:
capacity: 1Gi
class: shared

18
serviceaccount.yml Normal file
View File

@@ -0,0 +1,18 @@
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: oidc-gateway-madis
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: passmower
subjects:
- kind: ServiceAccount
name: oidc-gateway
namespace: hard2k1ll-72zn4
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: oidc-gateway