Add Timepoll availability polling application and image
Build custom container images / build (map[base_image:php:8-fpm-alpine build_args:PHP_VERSION=8
context:php8-pgsql fingerprint_command:{ apk info -v | LC_ALL=C sort; find /usr/local/lib/php/extensions /usr/local/etc/php/conf.d -type f -exec sha256sum {} + | LC_ALL=C sort; }
name:ph… (push) Successful in 49s
Build custom container images / build (map[base_image:postgres:18 build_args:PG_VERSION=18
POSTGIS_VERSION=3
VCHORD_VERSION=0.5.3
context:postgres fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; find /usr/lib/postgresql -type f -exec sha256su… (push) Successful in 1m42s
Build custom container images / build (map[base_image:python:3 build_args:PYTHON_VERSION=3
context:python-tools fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:python-tools oci_labels:org.opencontainers.ima… (push) Successful in 54s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim
context:linkki-tiedotus fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:linkki-tiedotus oci_labels:… (push) Successful in 29s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim
context:timepoll fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:timepoll oci_labels:org.opencontai… (push) Successful in 44s
Build custom container images / build (map[base_image:php:8-fpm-alpine build_args:PHP_VERSION=8
context:php8-pgsql fingerprint_command:{ apk info -v | LC_ALL=C sort; find /usr/local/lib/php/extensions /usr/local/etc/php/conf.d -type f -exec sha256sum {} + | LC_ALL=C sort; }
name:ph… (push) Successful in 49s
Build custom container images / build (map[base_image:postgres:18 build_args:PG_VERSION=18
POSTGIS_VERSION=3
VCHORD_VERSION=0.5.3
context:postgres fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; find /usr/lib/postgresql -type f -exec sha256su… (push) Successful in 1m42s
Build custom container images / build (map[base_image:python:3 build_args:PYTHON_VERSION=3
context:python-tools fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:python-tools oci_labels:org.opencontainers.ima… (push) Successful in 54s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim
context:linkki-tiedotus fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:linkki-tiedotus oci_labels:… (push) Successful in 29s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim
context:timepoll fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:timepoll oci_labels:org.opencontai… (push) Successful in 44s
This commit is contained in:
@@ -0,0 +1,501 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
import time
|
||||
import unicodedata
|
||||
from collections import defaultdict, deque
|
||||
from contextlib import contextmanager
|
||||
from itertools import pairwise
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError, available_timezones
|
||||
|
||||
from domain import make_slots, remap_votes, resize_slots, summaries, votes_lost
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||
ROOT = Path(__file__).parent
|
||||
|
||||
|
||||
def normalize_code(value):
|
||||
return (
|
||||
"".join(value.upper().split())
|
||||
.replace("-", "")
|
||||
.translate(str.maketrans("OIL", "011"))
|
||||
)
|
||||
|
||||
|
||||
def new_code(length):
|
||||
return "".join(secrets.choice(ALPHABET) for _ in range(length))
|
||||
|
||||
|
||||
class Schedule(BaseModel):
|
||||
dates: list[str] = Field(max_length=366)
|
||||
start: str = "09:00"
|
||||
end: str = "17:00"
|
||||
minutes: int = 30
|
||||
timezone: str
|
||||
|
||||
@field_validator("minutes")
|
||||
@classmethod
|
||||
def slot_size(cls, value):
|
||||
if value not in (15, 30, 60):
|
||||
raise ValueError("Choose 15, 30 or 60 minutes.")
|
||||
return value
|
||||
|
||||
@field_validator("timezone")
|
||||
@classmethod
|
||||
def zone(cls, value):
|
||||
try:
|
||||
ZoneInfo(value)
|
||||
except (ZoneInfoNotFoundError, ValueError):
|
||||
raise ValueError("Choose an IANA location timezone.") from None
|
||||
return value
|
||||
|
||||
|
||||
class PollSettings(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=120)
|
||||
timezone: str
|
||||
fixed_timezone: bool = False
|
||||
minutes: int = 30
|
||||
slots: list[int] = Field(min_length=1, max_length=12000)
|
||||
blocked: list[int] = Field(default_factory=list, max_length=12000)
|
||||
settings_revision: int = 0
|
||||
confirm_loss: bool = False
|
||||
|
||||
_size = field_validator("minutes")(Schedule.slot_size.__func__)
|
||||
_zone = field_validator("timezone")(Schedule.zone.__func__)
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
def title_valid(cls, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("Enter an event name.")
|
||||
return value
|
||||
|
||||
|
||||
class Votes(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
votes: dict[str, str] = Field(default_factory=dict, max_length=12000)
|
||||
revision: int = 0
|
||||
schedule_revision: int
|
||||
|
||||
|
||||
def create_app(data_dir=None):
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
data = Path(data_dir or os.environ.get("TIMEPOLL_DATA_DIR", "/data"))
|
||||
data.mkdir(parents=True, exist_ok=True)
|
||||
database = data / "timepoll.sqlite3"
|
||||
|
||||
@contextmanager
|
||||
def db(write=False):
|
||||
con = sqlite3.connect(database, timeout=10, isolation_level=None)
|
||||
con.row_factory = sqlite3.Row
|
||||
con.execute("PRAGMA foreign_keys=ON")
|
||||
try:
|
||||
con.execute("BEGIN IMMEDIATE" if write else "BEGIN")
|
||||
yield con
|
||||
con.commit()
|
||||
except BaseException:
|
||||
con.rollback()
|
||||
raise
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
with sqlite3.connect(database) as con:
|
||||
con.execute("PRAGMA journal_mode=WAL")
|
||||
con.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS polls (
|
||||
code TEXT PRIMARY KEY, admin_hash TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL, timezone TEXT NOT NULL, fixed_timezone INTEGER NOT NULL,
|
||||
minutes INTEGER NOT NULL, slots TEXT NOT NULL, blocked TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1, schedule_revision INTEGER NOT NULL DEFAULT 1,
|
||||
settings_revision INTEGER NOT NULL DEFAULT 1);
|
||||
CREATE TABLE IF NOT EXISTS people (
|
||||
id INTEGER PRIMARY KEY, poll TEXT NOT NULL REFERENCES polls(code) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL, name_key TEXT NOT NULL, votes TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1, UNIQUE(poll, name_key));
|
||||
""")
|
||||
|
||||
limits = defaultdict(deque)
|
||||
|
||||
@app.middleware("http")
|
||||
async def guards(request, call_next):
|
||||
if request.method in ("POST", "PUT", "DELETE"):
|
||||
origin = request.headers.get("origin")
|
||||
if origin and origin != str(request.base_url).rstrip("/"):
|
||||
return JSONResponse(
|
||||
{"detail": "Cross-origin writes are not allowed."}, 403
|
||||
)
|
||||
# Limit actual bytes, including chunked requests without Content-Length.
|
||||
body = bytearray()
|
||||
async for chunk in request.stream():
|
||||
body.extend(chunk)
|
||||
if len(body) > 1024 * 1024:
|
||||
return JSONResponse({"detail": "Request too large."}, 413)
|
||||
request._body = bytes(body)
|
||||
if request.url.path.startswith("/api/") and not request.url.path.endswith(
|
||||
"/events"
|
||||
):
|
||||
now = time.monotonic()
|
||||
# Bound memory and expire idle addresses, including rate-limit keys.
|
||||
for key in list(limits):
|
||||
if not limits[key] or limits[key][-1] < now - 3600:
|
||||
del limits[key]
|
||||
creating = request.method == "POST" and request.url.path == "/api/polls"
|
||||
key = (request.client.host, creating)
|
||||
window, maximum = (3600, 20) if creating else (60, 240)
|
||||
queue = limits[key]
|
||||
while queue and queue[0] <= now - window:
|
||||
queue.popleft()
|
||||
if len(queue) >= maximum:
|
||||
return JSONResponse(
|
||||
{"detail": "Too many requests. Please try again later."},
|
||||
429,
|
||||
headers={"Retry-After": str(window)},
|
||||
)
|
||||
queue.append(now)
|
||||
response = await call_next(request)
|
||||
response.headers.update(
|
||||
{
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Frame-Options": "DENY",
|
||||
"Content-Security-Policy": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
|
||||
}
|
||||
)
|
||||
if request.url.path.startswith("/api/"):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
def load(con, code):
|
||||
row = con.execute(
|
||||
"SELECT * FROM polls WHERE code=?", (normalize_code(code),)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Poll not found. Check the event code.")
|
||||
poll = dict(row)
|
||||
poll["slots"] = json.loads(poll["slots"])
|
||||
poll["blocked"] = json.loads(poll["blocked"])
|
||||
poll["fixed_timezone"] = bool(poll["fixed_timezone"])
|
||||
return poll
|
||||
|
||||
def people(con, code):
|
||||
return [
|
||||
{
|
||||
"id": r["id"],
|
||||
"name": r["name"],
|
||||
"votes": json.loads(r["votes"]),
|
||||
"revision": r["revision"],
|
||||
}
|
||||
for r in con.execute(
|
||||
"SELECT * FROM people WHERE poll=? ORDER BY id", (code,)
|
||||
)
|
||||
]
|
||||
|
||||
def authorize(request, poll):
|
||||
token = normalize_code(
|
||||
request.headers.get("Authorization", "").removeprefix("Bearer ")
|
||||
)
|
||||
if not secrets.compare_digest(
|
||||
hashlib.sha256(token.encode()).hexdigest(), poll["admin_hash"]
|
||||
):
|
||||
raise HTTPException(403, "The private admin link is required.")
|
||||
|
||||
def check_settings(settings):
|
||||
slots = sorted(set(settings.slots))
|
||||
if any(s < 0 or s > 4102444800 or s % 60 for s in slots):
|
||||
raise HTTPException(
|
||||
422, "Slots must be minute-aligned dates between 1970 and 2100."
|
||||
)
|
||||
if any(b - a < settings.minutes * 60 for a, b in pairwise(slots)):
|
||||
raise HTTPException(422, "Time slots cannot overlap.")
|
||||
if not set(settings.blocked) <= set(slots):
|
||||
raise HTTPException(422, "Blocked times must belong to the schedule.")
|
||||
return slots, sorted(set(settings.blocked))
|
||||
|
||||
@app.get("/healthz")
|
||||
def health():
|
||||
with db() as con:
|
||||
con.execute("SELECT 1 FROM polls LIMIT 1").fetchone()
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/api/timezones")
|
||||
def zones():
|
||||
return sorted(available_timezones())
|
||||
|
||||
@app.post("/api/schedule")
|
||||
def preview(schedule: Schedule):
|
||||
try:
|
||||
slots = make_slots(
|
||||
schedule.dates,
|
||||
schedule.start,
|
||||
schedule.end,
|
||||
schedule.minutes,
|
||||
schedule.timezone,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, str(exc)) from exc
|
||||
if not slots or len(slots) > 12000:
|
||||
raise HTTPException(422, "Choose between 1 and 12,000 slots.")
|
||||
return {"slots": slots}
|
||||
|
||||
@app.post("/api/resize")
|
||||
def resize(settings: PollSettings, old_minutes: int):
|
||||
if old_minutes not in (15, 30, 60):
|
||||
raise HTTPException(422, "Invalid previous slot size.")
|
||||
slots = resize_slots(
|
||||
sorted(set(settings.slots)),
|
||||
old_minutes,
|
||||
settings.minutes,
|
||||
settings.timezone,
|
||||
)
|
||||
blocked = remap_votes(
|
||||
{str(s): "yes" for s in settings.blocked},
|
||||
old_minutes,
|
||||
slots,
|
||||
settings.minutes,
|
||||
)
|
||||
return {"slots": slots, "blocked": [int(s) for s in blocked]}
|
||||
|
||||
@app.post("/api/polls", status_code=201)
|
||||
def create(settings: PollSettings):
|
||||
slots, blocked = check_settings(settings)
|
||||
with db(True) as con:
|
||||
while True:
|
||||
code, token = new_code(9), new_code(12)
|
||||
digest = hashlib.sha256(token.encode()).hexdigest()
|
||||
try:
|
||||
con.execute(
|
||||
"INSERT INTO polls(code,admin_hash,title,timezone,fixed_timezone,minutes,slots,blocked) VALUES(?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
code,
|
||||
digest,
|
||||
settings.title,
|
||||
settings.timezone,
|
||||
settings.fixed_timezone,
|
||||
settings.minutes,
|
||||
json.dumps(slots),
|
||||
json.dumps(blocked),
|
||||
),
|
||||
)
|
||||
break
|
||||
except sqlite3.IntegrityError:
|
||||
if (
|
||||
con.execute(
|
||||
"SELECT 1 FROM polls WHERE code=? OR admin_hash=?",
|
||||
(code, digest),
|
||||
).fetchone()
|
||||
is None
|
||||
):
|
||||
raise
|
||||
return {"code": code, "admin_token": token}
|
||||
|
||||
@app.get("/api/polls/{code}")
|
||||
def get_poll(code: str):
|
||||
with db() as con:
|
||||
poll = load(con, code)
|
||||
poll.pop("admin_hash")
|
||||
poll["people"] = people(con, poll["code"])
|
||||
active = sorted(set(poll["slots"]) - set(poll["blocked"]))
|
||||
poll["summaries"] = summaries(active, poll["minutes"], poll["people"])
|
||||
return poll
|
||||
|
||||
@app.get("/api/polls/{code}/admin")
|
||||
def admin(code: str, request: Request):
|
||||
with db() as con:
|
||||
authorize(request, load(con, code))
|
||||
return {"authorized": True}
|
||||
|
||||
@app.post("/api/admin/resolve")
|
||||
def resolve_admin(request: Request):
|
||||
token = normalize_code(
|
||||
request.headers.get("Authorization", "").removeprefix("Bearer ")
|
||||
)
|
||||
with db() as con:
|
||||
row = con.execute(
|
||||
"SELECT code FROM polls WHERE admin_hash=?",
|
||||
(hashlib.sha256(token.encode()).hexdigest(),),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(403, "The private admin link is invalid.")
|
||||
return {"code": row["code"]}
|
||||
|
||||
@app.put("/api/polls/{code}")
|
||||
def update(code: str, settings: PollSettings, request: Request):
|
||||
slots, blocked = check_settings(settings)
|
||||
with db(True) as con:
|
||||
poll = load(con, code)
|
||||
authorize(request, poll)
|
||||
if settings.settings_revision != poll["settings_revision"]:
|
||||
raise HTTPException(
|
||||
409, "This poll was edited elsewhere. Reload before saving."
|
||||
)
|
||||
active = sorted(set(slots) - set(blocked))
|
||||
participants = people(con, poll["code"])
|
||||
if not settings.confirm_loss and any(
|
||||
votes_lost(p["votes"], poll["minutes"], active, settings.minutes)
|
||||
for p in participants
|
||||
):
|
||||
raise HTTPException(
|
||||
409,
|
||||
{
|
||||
"code": "vote_loss",
|
||||
"message": "Some existing votes will be lost. Save these changes?",
|
||||
},
|
||||
)
|
||||
changed = (
|
||||
slots != poll["slots"]
|
||||
or blocked != poll["blocked"]
|
||||
or settings.minutes != poll["minutes"]
|
||||
)
|
||||
if changed:
|
||||
for person in participants:
|
||||
votes = remap_votes(
|
||||
person["votes"], poll["minutes"], active, settings.minutes
|
||||
)
|
||||
con.execute(
|
||||
"UPDATE people SET votes=?, revision=revision+1 WHERE id=?",
|
||||
(json.dumps(votes), person["id"]),
|
||||
)
|
||||
con.execute(
|
||||
"UPDATE polls SET title=?, timezone=?, fixed_timezone=?, minutes=?, slots=?, blocked=?, revision=revision+1, settings_revision=settings_revision+1, schedule_revision=schedule_revision+? WHERE code=?",
|
||||
(
|
||||
settings.title,
|
||||
settings.timezone,
|
||||
settings.fixed_timezone,
|
||||
settings.minutes,
|
||||
json.dumps(slots),
|
||||
json.dumps(blocked),
|
||||
int(changed),
|
||||
poll["code"],
|
||||
),
|
||||
)
|
||||
return {"saved": True}
|
||||
|
||||
def save_person(code, body, person_id=None):
|
||||
name = " ".join(body.name.split())
|
||||
if not name:
|
||||
raise HTTPException(422, "Enter your name.")
|
||||
name_key = unicodedata.normalize("NFKC", name).casefold()
|
||||
with db(True) as con:
|
||||
poll = load(con, code)
|
||||
if body.schedule_revision != poll["schedule_revision"]:
|
||||
raise HTTPException(
|
||||
409,
|
||||
"The available times changed. Cancel and review the new schedule before saving.",
|
||||
)
|
||||
active = {str(s) for s in set(poll["slots"]) - set(poll["blocked"])}
|
||||
if not set(body.votes) <= active or any(
|
||||
v not in ("yes", "maybe") for v in body.votes.values()
|
||||
):
|
||||
raise HTTPException(
|
||||
422, "Votes must refer to available slots and use yes or maybe."
|
||||
)
|
||||
if person_id is not None:
|
||||
person = con.execute(
|
||||
"SELECT * FROM people WHERE id=? AND poll=?",
|
||||
(person_id, poll["code"]),
|
||||
).fetchone()
|
||||
if person is None or person["revision"] != body.revision:
|
||||
raise HTTPException(
|
||||
409,
|
||||
"This participant was changed or removed elsewhere. Cancel and reload their votes.",
|
||||
)
|
||||
elif (
|
||||
con.execute(
|
||||
"SELECT count(*) FROM people WHERE poll=?", (poll["code"],)
|
||||
).fetchone()[0]
|
||||
>= 200
|
||||
):
|
||||
raise HTTPException(
|
||||
422, "This poll has reached its limit of 200 participants."
|
||||
)
|
||||
try:
|
||||
if person_id is None:
|
||||
person_id = con.execute(
|
||||
"INSERT INTO people(poll,name,name_key,votes) VALUES(?,?,?,?)",
|
||||
(poll["code"], name, name_key, json.dumps(body.votes)),
|
||||
).lastrowid
|
||||
else:
|
||||
con.execute(
|
||||
"UPDATE people SET name=?, name_key=?, votes=?, revision=revision+1 WHERE id=?",
|
||||
(name, name_key, json.dumps(body.votes), person_id),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise HTTPException(
|
||||
409, "That name is already in this poll. Choose a different name."
|
||||
) from exc
|
||||
con.execute(
|
||||
"UPDATE polls SET revision=revision+1 WHERE code=?", (poll["code"],)
|
||||
)
|
||||
return {"id": person_id}
|
||||
|
||||
@app.post("/api/polls/{code}/people", status_code=201)
|
||||
def add_person(code: str, body: Votes):
|
||||
return save_person(code, body)
|
||||
|
||||
@app.put("/api/polls/{code}/people/{person_id}")
|
||||
def edit_person(code: str, person_id: int, body: Votes):
|
||||
return save_person(code, body, person_id)
|
||||
|
||||
@app.delete("/api/polls/{code}/people/{person_id}")
|
||||
def delete_person(code: str, person_id: int, revision: int):
|
||||
with db(True) as con:
|
||||
poll = load(con, code)
|
||||
cursor = con.execute(
|
||||
"DELETE FROM people WHERE poll=? AND id=? AND revision=?",
|
||||
(poll["code"], person_id, revision),
|
||||
)
|
||||
if not cursor.rowcount:
|
||||
raise HTTPException(
|
||||
409,
|
||||
"This participant was changed or removed elsewhere. Reload before removing them.",
|
||||
)
|
||||
con.execute(
|
||||
"UPDATE polls SET revision=revision+1 WHERE code=?", (poll["code"],)
|
||||
)
|
||||
return {"removed": True}
|
||||
|
||||
@app.get("/api/polls/{code}/events")
|
||||
async def events(code: str, request: Request):
|
||||
def revision():
|
||||
with db() as con:
|
||||
return load(con, code)["revision"]
|
||||
|
||||
await asyncio.to_thread(revision)
|
||||
|
||||
async def stream():
|
||||
previous = None
|
||||
while not await request.is_disconnected():
|
||||
current = await asyncio.to_thread(revision)
|
||||
if current != previous:
|
||||
yield f"data: {current}\n\n"
|
||||
previous = current
|
||||
else:
|
||||
yield ": heartbeat\n\n"
|
||||
await asyncio.sleep(2)
|
||||
|
||||
return StreamingResponse(
|
||||
stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={"X-Accel-Buffering": "no", "Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
app.mount("/static", StaticFiles(directory=ROOT / "static"), name="static")
|
||||
|
||||
@app.get("/")
|
||||
@app.get("/new")
|
||||
@app.get("/{link_code}")
|
||||
def page():
|
||||
return FileResponse(
|
||||
ROOT / "static" / "index.html", headers={"Cache-Control": "no-cache"}
|
||||
)
|
||||
|
||||
return app
|
||||
Reference in New Issue
Block a user