Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ab5fc4570 |
13
Dockerfile
13
Dockerfile
@@ -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"]
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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
117
inventory-app/api.py
Normal 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")
|
||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}))
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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():
|
||||||
|
|||||||
@@ -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 %} {% endif %}</td> -->
|
<!-- <td>{% if o.success %}<i class="material-icons">check_circle</i>{% else %} {% 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 %}
|
||||||
|
|||||||
82
inventory-app/templates/doorboy_admin.html
Normal file
82
inventory-app/templates/doorboy_admin.html
Normal 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 %}
|
||||||
@@ -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 %}
|
|
||||||
@@ -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
8
minio.yml
Normal 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
18
serviceaccount.yml
Normal 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
|
||||||
Reference in New Issue
Block a user