19 Commits

Author SHA1 Message Date
Mykhailo Yermolenko
692438ea2b Add hold-door web form with proper authorization
- compute approved from group membership (was hardcoded True)
- allow duration=0 to cancel/close (InputRequired, min=0)
- pass HoldDoorForm to template so CSRF token renders
- add hold form section to doorboy.html
- index doorlog (door, approved, timestamp) for the proxy query
2026-06-17 16:42:54 +03:00
48bd9f8cdc doorboy documented at 2026-06-12 02:18:38 +03:00
eba8b21dbb Basic doorlog access 2026-06-12 01:49:52 +03:00
7a874593dc doorlog refactor to only username 2026-06-12 00:15:14 +03:00
6d8a7101ed s/eventlog/doorlog/ 2026-06-12 00:14:23 +03:00
4b2c2f6368 slimming doorboy admin methods
- enable/disable unused and broken, edit is good enough
- admin use-case last many years has only been 'when has x'
  - can be expanded to 'who at x time'
  - current was broken anyway
2025-12-30 03:52:48 +02:00
44c64cc44e split image functions to inventory_image.py 2025-12-29 00:50:26 +02:00
194420d375 drop dead s3 code
use region from env as well
2025-12-29 00:19:57 +02:00
2aecae99fc mongodb.member does not exist for years 2025-12-28 21:40:59 +02:00
af22be5434 use COOKIES_SECRET_KEY 2025-12-22 18:29:40 +02:00
b97746fa76 fix internal import 2025-12-22 18:17:06 +02:00
fc15281129 Merge pull request 'doorboy-direct' (#36) from doorboy-direct into master
Reviewed-on: k-space/inventory-app#36
Reviewed-by: Arti Zirk <arti@k-space.ee>
2025-12-22 15:51:29 +00:00
fb8c63e86f move static env to dockerfile 2025-08-08 06:11:55 +03:00
14a5e8e42d rm SECRET_KEY 2025-08-08 05:51:57 +03:00
f0109677b0 doc k-space:floor hardcoded 2025-08-08 05:51:57 +03:00
ac14ca1adf move slack_listen to doorboy-proxy 2025-08-08 05:51:54 +03:00
76cc8e6883 move /cards to doorboy-proxy + refactor mongo format 2025-08-08 03:34:06 +03:00
ee064bde2d move /m/doorboy/swipe to doorboy-proxy 2025-08-07 23:59:11 +03:00
6bba3f9831 doorboy: merge status, success and approved 2025-08-07 23:51:52 +03:00
15 changed files with 326 additions and 677 deletions

View File

@@ -4,12 +4,17 @@ 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/* \
&& apt-get clean
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
#necessary hack for cffi
RUN pip3 install cffi
RUN pip3 install -r requirements.txt
COPY inventory-app /app
ENV PYTHONUNBUFFERED=1
ENV ENVIRONMENT_TYPE=PROD
WORKDIR /app
ENTRYPOINT /app/main.py
COPY inventory-app .
ENTRYPOINT ["python3", "/app/main.py"]

View File

@@ -10,3 +10,5 @@
|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,3 +1,4 @@
# This is unmaintained. See git.k-space.ee/k-space/kube//hackerspace/inventory.yaml
---
apiVersion: apps/v1
kind: Deployment
@@ -23,33 +24,11 @@ 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
- name: RECAPTCHA_PRIVATE_KEY
value: 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe
- name: INVENTORY_ASSETS_BASE_URL
value: "https://minio.codemowers.eu:9000"
- name: MONGO_URI
valueFrom:
secretKeyRef:
name: mongodb-application-readwrite
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
value: "DEV"
- name: MY_POD_NAME

View File

@@ -1,117 +0,0 @@
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,17 +1,16 @@
import os
import collections.abc
import os
from dataclasses import dataclass, field
from functools import wraps
from typing import List
import requests
import const
from bson.objectid import ObjectId
from flask import g, request
from flask_wtf import FlaskForm
from pymongo import MongoClient
from kubernetes import client, config
from typing import List
from dataclasses import dataclass, field
import const
from oidc import read_user
from pymongo import MongoClient
devenv = const.ENVIRONMENT_TYPE == "DEV"
OIDC_USERS_NAMESPACE = os.getenv("OIDC_USERS_NAMESPACE")
@@ -93,18 +92,6 @@ 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,12 +12,8 @@ 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"]
COOKIES_SECRET_KEY = os.environ["COOKIES_SECRET_KEY"] # session storage, random chars

View File

@@ -1,22 +1,22 @@
from datetime import datetime, timedelta
from dateutil.parser import parse, ParserError
from bson.objectid import ObjectId
from flask import Blueprint, g, redirect, render_template, request, abort
from flask_wtf import FlaskForm
from pymongo import MongoClient
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 bson.objectid import ObjectId
from common import User
from flask import Blueprint, abort, g, redirect, render_template, request
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.validators import DataRequired, InputRequired
page_doorboy = Blueprint("doorboy", __name__)
db = MongoClient(const.MONGO_URI).get_default_database()
###########################################################################
## DOORBOY IS DOCUMENTED AT https://git.k-space.ee/k-space/doorboy-proxy ##
###########################################################################
@page_doorboy.route("/m/doorboy/<event_id>/claim")
@login_required
def view_doorboy_claim(event_id):
@@ -28,7 +28,7 @@ def view_doorboy_claim(event_id):
})
# Find token object to associate with user
token = db.inventory.update_one({
db.inventory.update_one({
"type": "token",
"token.uid_hash": event["token"]["uid_hash"],
"inventory.owner.username": { "$exists": False }
@@ -42,48 +42,6 @@ def view_doorboy_claim(event_id):
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):
comment = StringField("Comment")
enabled = BooleanField("Enabled")
@@ -124,27 +82,54 @@ def save_doorboy_edit(token_id):
})
return redirect("/m/doorboy/me")
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)])
def hold_duration_check(form, field):
# 0 cancels/closes an active hold immediately; any real hold must last at
# least the documented 5-second minimum (and at most 6 hours). Reject 1-4
# so a typo cannot arm an unusably short hold. field.data is None when the
# submitted value was not a valid integer; leave that to the other validators.
if field.data is None:
return
if field.data != 0 and not (5 <= field.data <= 21600):
raise validators.ValidationError("Duration must be 0 (to close now) or between 5 and 21600 seconds")
class HoldDoorForm(FlaskForm):
door_name = SelectField("Door name", choices=[(j,j) for j in ["grounddoor", "frontdoor", "backdoor", "workshopdoor"]], validators=[DataRequired()])
duration = IntegerField('Duration in seconds', validators=[InputRequired(), hold_duration_check])
# 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",
"type": "hold",
"requester": user["name"],
"door": form.door_name.data,
if not form.validate_on_submit():
# Validation or CSRF failure: report it instead of redirecting as if the
# hold had been accepted, so the user knows the door will not be held.
errors = "; ".join(m for messages in form.errors.values() for m in messages)
return "Invalid hold request: %s" % (errors or "bad or expired form"), 400
door = form.door_name.data
if door == "workshopdoor":
access_group = "k-space:workshop"
else:
access_group = "k-space:floor"
approved = access_group in g.users_lookup.get(user["username"], User()).groups
db.doorlog.insert_one({
"method": "hold",
"timestamp": datetime.utcnow(),
"door": door,
"approved": approved,
"user": user["username"],
"expires": datetime.utcnow() + timedelta(seconds=form.duration.data)
})
if approved:
return redirect("/m/doorboy")
# Logged for audit, but the proxy only honors approved holds, so tell the
# user the door will not actually be held rather than redirecting silently.
return "You are not in the access group required to hold this door", 403
# Writes open event to log, which is picked up by doorboy-proxy.
@page_doorboy.route("/m/doorboy/<door>/open")
@login_required
def view_doorboy_open(door):
@@ -157,56 +142,28 @@ def view_doorboy_open(door):
else:
access_group = "k-space:floor"
approved = access_group in g.users_lookup.get(user["username"], User()).groups
db.eventlog.insert_one({
db.doorlog.insert_one({
"method": "web",
"approved": approved,
"duration": 5,
"component": "doorboy",
"type": "open-door",
"door": door,
"member_id": user["username"],
"member": user["name"],
"timestamp": datetime.utcnow(),
"door": door,
"approved": approved,
"user": user["username"],
})
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():
user = read_user()
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);
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)
hold_form = HoldDoorForm()
latest_swipes = db.inventory.find({"component": "doorboy", "type":"token"}).sort([("last_seen", -1)]).limit(10)
return render_template("doorboy.html", **locals())
@page_doorboy.route("/m/doorboy/me")
@login_required
@@ -227,141 +184,24 @@ def view_user_cards_inner(username):
}).sort([("last_seen", -1)])
return render_template("doorboy_user.html", **locals())
@page_doorboy.route("/m/doorboy/admin")
@page_doorboy.route("/m/doorboy/log")
@login_required(groups=["k-space:board", "k-space:kubernetes:admins"])
def view_doorboy_admin():
results = db.inventory.aggregate([
{ "$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())
def doorlog():
latest_events = db.doorlog.find({}).sort([("timestamp", -1)])
return render_template("doorboy_log.html", latest_events=latest_events)
@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())
@page_doorboy.route("/m/doorboy/log/<username>")
@login_required(groups=["k-space:board", "k-space:kubernetes:admins"])
def doorlog_user(username):
if username is None:
return redirect("/m/doorboy/log")
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')
latest_events = db.doorlog.find({"user": username}).sort([("timestamp", -1)])
return render_template("doorboy_log.html", latest_events=latest_events, info="Slack users without linked IDs opening from #members appear in <a href=\"/m/doorboy/log/_\">Unlinked users</a>.")
@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"
@page_doorboy.route("/m/doorboy/log/_")
@login_required(groups=["k-space:board", "k-space:kubernetes:admins"])
def doorlog_unknowns():
latest_events = db.doorlog.find({"user": {"$in": [None, ""]}}).sort([("timestamp", -1)])
return render_template("doorboy_log.html", latest_events=latest_events, info="Showing opens not linked to kube users.")

View File

@@ -1,26 +1,22 @@
import re
import boto3
import pymongo
import urllib
from datetime import datetime, date, timedelta
from botocore.exceptions import ClientError
from datetime import date, datetime, timedelta
import const
import inventory_image
import pymongo
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 jpegtran import JPEGImage
from oidc import do_login, login_required, read_user
from pymongo import MongoClient
from werkzeug.utils import secure_filename
from wtforms import BooleanField, SelectField, StringField
from wtforms.fields import FormField
from wtforms.form import Form
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__)
db = MongoClient(const.MONGO_URI).get_default_database()
channel = "inventory"
@login_required
@page_inventory.route("/m/inventory/by-mac/<mac>", methods=['GET'])
@@ -50,25 +46,11 @@ def view_inventory_view(item_id):
can_audit = "k-space:inventory:audit" in user.get("groups", [])
can_edit = check_edit_permission(item_id)
is_using = item_user and item_user == user["username"]
photo_url = get_image_url(item_id)
photo_url = inventory_image.get_image_url(item_id)
# pylance: disable=unused-variable
constants = {"MACADDRESS_OUTLINK_BASEURL": const.MACADDRESS_OUTLINK_BASEURL}
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():
top_usernames= ['k-space', None]
choices = [(None, None)]
@@ -256,128 +238,6 @@ def save_inventory_item(item_id=None, **_):
return render_template("inventory_edit.html", **locals())
return redirect("/m/inventory/%s/view" % item_id)
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-slug/<slug>")

View File

@@ -0,0 +1,151 @@
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,27 +7,23 @@ from functools import wraps
from http.server import BaseHTTPRequestHandler, HTTPServer
import bleach
import const
import jinja2
import markdown
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 inventory import page_inventory
from inventory_image import page_inventory_image
from jinja2 import Environment, FileSystemLoader
from markupsafe import Markup
from prometheus_client import Gauge, CollectorRegistry, generate_latest
from oidc import login_required, page_oidc
from prometheus_client import CollectorRegistry, Gauge, generate_latest
from prometheus_flask_exporter import PrometheusMetrics
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):
owner = item.get("inventory", {}).get("owner", {})
@@ -121,21 +117,24 @@ class ReverseProxied(object):
return self.app(environ, start_response)
app = Flask(__name__)
app.secret_key = const.COOKIES_SECRET_KEY
app.wsgi_app = ReverseProxied(app.wsgi_app)
app.register_blueprint(page_inventory)
app.register_blueprint(page_inventory_image)
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)
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", unique=True)
# Supports doorboy-proxy's per-poll "newest active hold for this door" lookup
# (find_one({method,door,approved}, sort=timestamp desc)) without a collection scan.
# method is the leading equality field so the common no-active-hold poll does not
# scan every approved event for the door; timestamp trails for the descending sort.
mongodb.doorlog.create_index([("method", 1), ("door", 1), ("approved", 1), ("timestamp", -1)])
CATEGORY_COLORS = (
('membership-fee', '#acc236'),
@@ -184,7 +183,7 @@ def do_before_request():
@app.context_processor
def inject_context():
return dict(devenv=devenv, inventory_assets_base_url=const.INVENTORY_ASSETS_BASE_URL)
return dict(devenv=devenv)
def name_check(form, field):
if field.data != field.data.strip():

View File

@@ -37,6 +37,22 @@
</div>
</div>
<div>
<h3>Hold door open</h3>
<form method="POST" action="/m/doorboy/hold" autocomplete="off">
{{ hold_form.csrf_token }}
<select name="door_name">
<option value="grounddoor">Ground door</option>
<option value="frontdoor">Front door</option>
<option value="backdoor">Back door</option>
{% if workshop_access %}<option value="workshopdoor">Workshop</option>{% endif %}
</select>
<input type="number" name="duration" min="0" max="21600" value="300">
<button class="waves-effect waves-light btn" type="submit">Hold</button>
<p>Duration in seconds: 300 = 5 min, 3600 = 1h, max 21600 = 6h, 0 = cancel/close now.</p>
</form>
</div>
<hr>
<div><ul>
<li>Doors locations: <a href="https://k-space.ee/where">k-space.ee/where</a></li>
@@ -84,7 +100,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.success %}<i class="material-icons">check_circle</i>{% else %}&nbsp;{% endif %}</td> -->
<!-- <td>{% if o.approved %}<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

@@ -1,82 +0,0 @@
{% 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

@@ -0,0 +1,39 @@
{% extends 'base.html' %}
{% block content %}
{% if info -%}
<h3>{{ info | safe }}</h3>
{%- endif %}
<p>Approved means door actually opened. Unapproved opens are failed attempts.</p>
<table>
<thead>
<tr>
<th>Approved</th>
<th>Timestamp</th>
<th>Door</th>
<th>Who</th>
<th>Method</th>
<th>Method details</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>{% if o.user %}<a href="log/{{ o.user }}">{{ o.user }}</a>{% endif %}</td>
<td>{{ o.method }}</td>
<td>{% if o.userExtra %}{% if o.method == "card" -%}
{{ o.userExtra[-6:] }}
{% else -%}
{{ o.userExtra }}
{%- endif %}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

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

View File

@@ -1,18 +0,0 @@
---
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