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
This commit is contained in:
Mykhailo Yermolenko
2026-06-17 16:42:54 +03:00
parent 48bd9f8cdc
commit 692438ea2b
3 changed files with 61 additions and 13 deletions

View File

@@ -8,7 +8,7 @@ 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
from wtforms.validators import DataRequired, InputRequired
page_doorboy = Blueprint("doorboy", __name__)
db = MongoClient(const.MONGO_URI).get_default_database()
@@ -82,9 +82,19 @@ def save_doorboy_edit(token_id):
})
return redirect("/m/doorboy/me")
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"]], validators=[DataRequired()])
duration = IntegerField('Duration in seconds', validators=[DataRequired(), validators.NumberRange(min=5, max=21600)])
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"])
@@ -92,16 +102,32 @@ class HoldDoorForm(FlaskForm):
def view_doorboy_hold():
user = read_user()
form = HoldDoorForm(request.form)
if form.validate_on_submit():
db.doorlog.insert_one({
"method": "hold",
"timestamp": datetime.utcnow(),
"door": form.door_name.data,
"approved": True,
"user": user["username"],
"expires": datetime.utcnow() + timedelta(seconds=form.duration.data)
})
return redirect("/m/doorboy")
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")
@@ -135,6 +161,7 @@ def view_doorboy():
user = read_user()
workshop_access = "k-space:workshop" in g.users_lookup.get(user["username"], User()).groups
hold_form = HoldDoorForm()
latest_swipes = db.inventory.find({"component": "doorboy", "type":"token"}).sort([("last_seen", -1)]).limit(10)
return render_template("doorboy.html", **locals())

View File

@@ -130,6 +130,11 @@ mongodb = mongoclient.get_default_database()
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'),

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>