Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cae116b50c | ||
|
|
d8528ff1f9 | ||
|
|
6c2e0cc299 | ||
|
|
9368086d81 | ||
|
|
e2497941c8 | ||
|
|
55fc61c514 | ||
|
|
3b17e6440e | ||
|
|
87d4dda0eb | ||
|
|
3604683df3 | ||
|
|
1193894b02 | ||
|
|
574d0bfe71 |
+9
-1
@@ -4,7 +4,15 @@ One line per change, grouped by the release that shipped it. Newest
|
||||
first. A release is a Cargo.toml bump plus a `v<version>` tag; the
|
||||
content repos pin the tag they run (see the end of this file).
|
||||
|
||||
## Unreleased
|
||||
## 0.6.5 (2026-09-30)
|
||||
|
||||
- Trainer: `paused: true` on a need. Its card stays on the front door with what the business does and a sentence saying it is not taking new ones right now, with no form and a button that does not press; nothing is recorded. The lint reports a paused need as a warning ("paused, not open now") and counts it on its own in the summary, as `planned` already did for needs not open yet. Klingenberg Bygg's building applications are paused for capacity.
|
||||
|
||||
## 0.6.4 (2026-09-30)
|
||||
|
||||
- The rate limit is a hundred at once, refilled at ten a second (it was 30, one every two seconds, which a page with a picture strip used up in one load: a few reloads and the stylesheet was refused). The files a page is made of, from portal's own disk, are not counted at all. `PORTAL_RATE_PER_SECOND` replaces `PORTAL_RATE_SECONDS`; no site set it.
|
||||
|
||||
## 0.6.3 (2026-09-30)
|
||||
|
||||
- Pages move the way their addresses do. Deeper in the address comes in from the right, back up comes in from the left, and a page at the same depth comes from below or above, with the index on top. The cards on screen leave as layers of their own, one after another, and the next page's cards arrive the same way. The old page stays on screen until the new one has loaded, where it used to go blank in between. Only transforms and opacity are animated, through the browser's View Transitions (`public/page-motion.js`, before the router). Browsers without them, and anyone who asks for reduced motion, navigate as before.
|
||||
- A record that carries a picture, in a list with no buttons, is shown as a card: the picture, the first field as the heading, the rest under it, with no field names, state or time. Cards are shown in the order they were put up. On a desk, the same picture is a thumbnail.
|
||||
|
||||
Generated
+1
-1
@@ -3357,7 +3357,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "portal"
|
||||
version = "0.6.2"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "portal"
|
||||
version = "0.6.2"
|
||||
version = "0.6.5"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
+16
-7
@@ -82,22 +82,31 @@ values.
|
||||
| `GARAGE_*` | uploads |
|
||||
| `MAPBOX_TOKEN` | map tiles, proxied; unset, maps are off |
|
||||
| `GARAGE_*` (4) | attachments; see below. Unset, upload fields fail closed |
|
||||
| `PORTAL_RATE_BURST`, `PORTAL_RATE_SECONDS` | the rate limit; see below |
|
||||
| `PORTAL_RATE_BURST`, `PORTAL_RATE_PER_SECOND` | the rate limit; see below |
|
||||
|
||||
Mail is delivered by [gdo](https://project.uhhm.no/uhhm/gdo), a
|
||||
separate unit on the same host reading the same NATS.
|
||||
|
||||
## Rate limiting
|
||||
|
||||
Every request passes a per-caller-IP limit before a session is loaded
|
||||
or a handler is entered: a burst of 30, refilled one every two
|
||||
seconds. A visitor filling in a form never reaches it (a page load
|
||||
fetches several resources at once); a script does.
|
||||
Every request that makes portal do something passes a per-caller-IP
|
||||
limit before a session is loaded or a handler is entered: a burst of
|
||||
100, refilled at ten a second. More than a hundred requests in ten
|
||||
seconds is a script; someone reloading a page again and again stays
|
||||
well inside it.
|
||||
|
||||
The files a page is made of, served from portal's own disk, are not
|
||||
counted: the stylesheet and scripts under `/pkg`, the fonts, the
|
||||
favicons, the vendored libraries. Refusing one of those is what turns
|
||||
a reloaded page into an unstyled mess, and serving one costs next to
|
||||
nothing. Anything that makes portal ask someone else is counted: a
|
||||
content picture under `/site/` is fetched from Gitea, a map tile from
|
||||
the map provider.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `PORTAL_RATE_BURST` | how many requests at once, default 30 |
|
||||
| `PORTAL_RATE_SECONDS` | seconds per refilled request, default 2 |
|
||||
| `PORTAL_RATE_BURST` | how many requests at once, default 100 |
|
||||
| `PORTAL_RATE_PER_SECOND` | requests refilled per second, default 10 |
|
||||
|
||||
Either set to `0` turns it off, for an instance behind something that
|
||||
already does this. The caller is read from `X-Forwarded-For` or
|
||||
|
||||
@@ -100,7 +100,9 @@ needs:
|
||||
- { name: oppstart, type: date, optional: true }
|
||||
- { name: skisser, type: file, optional: true }
|
||||
|
||||
# På pause: fullt hos oss. Ta linjen ut for å åpne igjen.
|
||||
- id: soknad
|
||||
paused: true
|
||||
who: en byggherre med et ferdig prosjektert bygg som trenger offentlig godkjenning før første spadetak
|
||||
wants: en godkjent byggesøknad, med ansvarsrett der det kreves
|
||||
lands_in: soknader
|
||||
|
||||
@@ -109,7 +109,8 @@
|
||||
"telefon": "Telefonnummer",
|
||||
"adresse": "Adresse",
|
||||
"tiltak": "Tilbygg, ombygging, nybygg…"
|
||||
}
|
||||
},
|
||||
"paused": "Akkurat nå har vi ikke kapasitet til nye søknadsoppdrag; vi åpner igjen så snart vi kan."
|
||||
}
|
||||
},
|
||||
"who": {
|
||||
|
||||
+5
-3
@@ -180,11 +180,13 @@ fn check_needs(
|
||||
}
|
||||
}
|
||||
if failed == 0 {
|
||||
let planned = file.needs.iter().filter(|n| n.planned).count();
|
||||
let planned = file.needs.iter().filter(|n| n.planned && !n.paused).count();
|
||||
let paused = file.needs.iter().filter(|n| n.paused).count();
|
||||
println!(
|
||||
"OK: {} need(s) structurally met, {} planned, {} persona(s)",
|
||||
file.needs.len() - planned,
|
||||
"OK: {} need(s) structurally met, {} planned, {} paused, {} persona(s)",
|
||||
file.needs.len() - planned - paused,
|
||||
planned,
|
||||
paused,
|
||||
file.personas.len()
|
||||
);
|
||||
}
|
||||
|
||||
+9
-5
@@ -78,6 +78,10 @@ pub struct Need {
|
||||
/// as a warning, so the gap stays visible without failing CI.
|
||||
#[serde(default)]
|
||||
pub planned: bool,
|
||||
/// Open before and closed for now (full up, out of season): its
|
||||
/// card is on the page with no form. Reports like `planned`.
|
||||
#[serde(default)]
|
||||
pub paused: bool,
|
||||
/// Each state of the bucket in plain words, in the site's
|
||||
/// language, for the scorer: state ids are ASCII, and a Norwegian
|
||||
/// `ikke_lost` reads as English to a model.
|
||||
@@ -305,11 +309,11 @@ pub fn check(
|
||||
let mut report = |level: Level, message: String| {
|
||||
findings.push(Finding {
|
||||
need: need.id.clone(),
|
||||
level: if need.planned { Level::Warn } else { level },
|
||||
message: if need.planned && level == Level::Fail {
|
||||
format!("planned, not met yet: {message}")
|
||||
} else {
|
||||
message
|
||||
level: if need.planned || need.paused { Level::Warn } else { level },
|
||||
message: match (need.paused, need.planned, level == Level::Fail) {
|
||||
(true, _, true) => format!("paused, not open now: {message}"),
|
||||
(false, true, true) => format!("planned, not met yet: {message}"),
|
||||
_ => message,
|
||||
},
|
||||
})
|
||||
};
|
||||
|
||||
+54
-44
@@ -166,30 +166,32 @@ async fn serve() -> anyhow::Result<()> {
|
||||
.with_name("portal_session");
|
||||
|
||||
// What a person does costs a little; what a script does costs a
|
||||
// lot. A burst of 30 with one refilled every two seconds is well
|
||||
// above anything a visitor filling in a form will reach - a page
|
||||
// load fetches several resources at once - and well below what it
|
||||
// takes to be a nuisance. Per caller IP, in memory, no store: this
|
||||
// is a fence, not an accounting system, and the fence that matters
|
||||
// for uploads is the policy check on the route itself.
|
||||
// lot. More than a hundred requests in ten seconds is a script: a
|
||||
// burst of 100, refilled at ten a second. Someone reloading a page
|
||||
// again and again stays well inside it, the more so since the
|
||||
// files a page is made of (stylesheet, scripts, fonts, pictures)
|
||||
// are not counted at all - see `assets` below. Per caller IP, in
|
||||
// memory, no store: this is a fence, not an accounting system, and
|
||||
// the fence that matters for uploads is the policy check on the
|
||||
// route itself.
|
||||
//
|
||||
// PORTAL_RATE_PER_SECOND / PORTAL_RATE_BURST retune it per site;
|
||||
// 0 for either turns it off, for an instance behind something that
|
||||
// already does this.
|
||||
let rate_seconds: u64 = std::env::var("PORTAL_RATE_SECONDS")
|
||||
let rate_per_second: u64 = std::env::var("PORTAL_RATE_PER_SECOND")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(2);
|
||||
.unwrap_or(10);
|
||||
let rate_burst: u32 = std::env::var("PORTAL_RATE_BURST")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(30);
|
||||
let governor = (rate_seconds > 0 && rate_burst > 0).then(|| {
|
||||
.unwrap_or(100);
|
||||
let governor = (rate_per_second > 0 && rate_burst > 0).then(|| {
|
||||
// SmartIpKeyExtractor reads X-Forwarded-For/X-Real-IP, which is
|
||||
// what every one of these instances is behind; it falls back to
|
||||
// the socket address when neither is set.
|
||||
let conf = tower_governor::governor::GovernorConfigBuilder::default()
|
||||
.per_second(rate_seconds)
|
||||
.per_millisecond((1000 / rate_per_second).max(1))
|
||||
.burst_size(rate_burst)
|
||||
.key_extractor(tower_governor::key_extractor::SmartIpKeyExtractor)
|
||||
.finish()
|
||||
@@ -197,8 +199,8 @@ async fn serve() -> anyhow::Result<()> {
|
||||
std::sync::Arc::new(conf)
|
||||
});
|
||||
match &governor {
|
||||
Some(_) => tracing::info!(rate_seconds, rate_burst, "rate limiting per caller IP"),
|
||||
None => tracing::warn!("rate limiting is off (PORTAL_RATE_SECONDS or PORTAL_RATE_BURST is 0)"),
|
||||
Some(_) => tracing::info!(rate_per_second, rate_burst, "rate limiting per caller IP"),
|
||||
None => tracing::warn!("rate limiting is off (PORTAL_RATE_PER_SECOND or PORTAL_RATE_BURST is 0)"),
|
||||
}
|
||||
|
||||
async fn server_fn_handler(
|
||||
@@ -233,34 +235,13 @@ async fn serve() -> anyhow::Result<()> {
|
||||
let fonts_dir = format!("{}/fonts", leptos_options.site_root);
|
||||
let page_motion = format!("{}/page-motion.js", leptos_options.site_root);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/auth/login", get(oidc::login))
|
||||
.route("/auth/callback", get(oidc::callback))
|
||||
.route("/auth/logout", get(oidc::logout))
|
||||
.route("/api/{*fn_name}", any(server_fn_handler))
|
||||
// axum caps request bodies at 2 MB by default, which had made the
|
||||
// 20 MB upload limit unreachable; raise the fence on this route only,
|
||||
// with room for the multipart framing.
|
||||
.route(
|
||||
"/upload",
|
||||
post(upload::upload).layer(axum::extract::DefaultBodyLimit::max(upload::MAX_UPLOAD_BYTES + 64 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/attachment/{bucket}/{record}/{field}",
|
||||
get(upload::attachment),
|
||||
)
|
||||
.route("/gitea-repo", get(content::gitea_repo_handler))
|
||||
// What the storylines did, as counts (report.rs).
|
||||
.route("/report", get(portal::report::handler))
|
||||
// Content-shipped assets (hero module etc.), same-origin.
|
||||
.route("/site/{*path}", get(content::site_asset_handler))
|
||||
.route("/automation/kv/{bucket}", get(content::automation_kv_handler))
|
||||
// Maps, proxied: the browser asks this origin for every tile
|
||||
// and every still, so nothing third-party loads in a page and
|
||||
// the token stays here (see maps.rs).
|
||||
.route("/map/style.json", get(portal::maps::style))
|
||||
.route("/map/tiles/{z}/{x}/{y}", get(portal::maps::tile))
|
||||
.route("/map/still/{place}/{size}", get(portal::maps::still))
|
||||
// The files a page is made of, from this disk: the stylesheet, the
|
||||
// scripts, the fonts. Not counted by the rate limit - they cost
|
||||
// next to nothing, and refusing one is what turns a reloaded page
|
||||
// into an unstyled mess - and no session is loaded for them.
|
||||
// Anything that makes portal ask someone else (content pictures
|
||||
// from Gitea, map tiles) stays below, counted.
|
||||
let assets: Router<AppState> = Router::new()
|
||||
// no-cache = "revalidate before reuse", not "don't cache":
|
||||
// pkg files keep the same names across releases (portal.js,
|
||||
// portal.wasm), and without this browsers heuristically cache
|
||||
@@ -296,7 +277,36 @@ async fn serve() -> anyhow::Result<()> {
|
||||
.service(ServeFile::new(page_motion)),
|
||||
)
|
||||
.route_service("/maplibre-gl.js", ServeFile::new(maplibre_js))
|
||||
.route_service("/maplibre-gl.css", ServeFile::new(maplibre_css))
|
||||
.route_service("/maplibre-gl.css", ServeFile::new(maplibre_css));
|
||||
|
||||
let app = Router::new()
|
||||
.route("/auth/login", get(oidc::login))
|
||||
.route("/auth/callback", get(oidc::callback))
|
||||
.route("/auth/logout", get(oidc::logout))
|
||||
.route("/api/{*fn_name}", any(server_fn_handler))
|
||||
// axum caps request bodies at 2 MB by default, which had made the
|
||||
// 20 MB upload limit unreachable; raise the fence on this route only,
|
||||
// with room for the multipart framing.
|
||||
.route(
|
||||
"/upload",
|
||||
post(upload::upload).layer(axum::extract::DefaultBodyLimit::max(upload::MAX_UPLOAD_BYTES + 64 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/attachment/{bucket}/{record}/{field}",
|
||||
get(upload::attachment),
|
||||
)
|
||||
.route("/gitea-repo", get(content::gitea_repo_handler))
|
||||
// What the storylines did, as counts (report.rs).
|
||||
.route("/report", get(portal::report::handler))
|
||||
// Content-shipped assets (hero module etc.), same-origin.
|
||||
.route("/site/{*path}", get(content::site_asset_handler))
|
||||
.route("/automation/kv/{bucket}", get(content::automation_kv_handler))
|
||||
// Maps, proxied: the browser asks this origin for every tile
|
||||
// and every still, so nothing third-party loads in a page and
|
||||
// the token stays here (see maps.rs).
|
||||
.route("/map/style.json", get(portal::maps::style))
|
||||
.route("/map/tiles/{z}/{x}/{y}", get(portal::maps::tile))
|
||||
.route("/map/still/{place}/{size}", get(portal::maps::still))
|
||||
.leptos_routes_with_context(
|
||||
&state,
|
||||
routes,
|
||||
@@ -318,8 +328,8 @@ async fn serve() -> anyhow::Result<()> {
|
||||
let app = match governor {
|
||||
Some(conf) => app.layer(tower_governor::GovernorLayer::new(conf)),
|
||||
None => app,
|
||||
}
|
||||
.with_state(state);
|
||||
};
|
||||
let app = assets.merge(app).with_state(state);
|
||||
|
||||
tracing::info!("listening on http://{addr}");
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
|
||||
+29
-4
@@ -425,6 +425,14 @@ pub struct PlanNeed {
|
||||
/// picture.
|
||||
#[serde(default)]
|
||||
pub presents: bool,
|
||||
/// Not taking new cases for now: full up, a season closed. The
|
||||
/// card stays where it is, saying what the business does and that
|
||||
/// it is paused, with no form to fill in and a button that does not
|
||||
/// press; the server refuses a submission. Remove the line to open
|
||||
/// again - the desk, the stages and every case under way are
|
||||
/// untouched either way.
|
||||
#[serde(default)]
|
||||
pub paused: bool,
|
||||
pub collects: Vec<Field>,
|
||||
/// How many required fields this person will tolerate: what the
|
||||
/// business said about them, not what the form happens to ask.
|
||||
@@ -1082,6 +1090,8 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
|
||||
"button": "Send",
|
||||
"encouragement": "",
|
||||
"icon": "",
|
||||
// Said after the description, on a paused need's card only.
|
||||
"paused": if need.paused { loc(lang, "We are not taking new ones right now, and open again as soon as we can.", "Akkurat nå tar vi ikke imot nye; vi åpner igjen så snart vi kan.") } else { String::new() },
|
||||
// Three sections of context the plan already knows: what
|
||||
// the person must have for this to work, the road a case
|
||||
// travels, and who is at the other end. The fallbacks are
|
||||
@@ -1957,15 +1967,29 @@ fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Va
|
||||
if !s(&w["who_description"]).trim().is_empty() {
|
||||
features.push(strip(&w["who_title"], &w["who_description"], s(&w["who_icon"])));
|
||||
}
|
||||
features.push(feature);
|
||||
// A paused need asks for nothing: a form nobody can send has
|
||||
// already wasted the time it took to fill in.
|
||||
if !need.paused {
|
||||
features.push(feature);
|
||||
}
|
||||
let description = if need.paused {
|
||||
text_map(&w["description"], |d| format!("{} {}", d.trim(), s(&w["paused"]).trim()).trim().to_string())
|
||||
} else {
|
||||
text(&w["description"])
|
||||
};
|
||||
let mut alt = json!({
|
||||
"name": text(&w["heading"]),
|
||||
"description": text(&w["description"]),
|
||||
"description": description,
|
||||
"action": action,
|
||||
"consequence": button(&w["button"]),
|
||||
"record_as": need.lands_in,
|
||||
"features": features,
|
||||
});
|
||||
// Paused, it records nothing and says where nothing goes.
|
||||
if need.paused {
|
||||
alt["disabled"] = json!(true);
|
||||
alt.as_object_mut().map(|a| a.remove("record_as"));
|
||||
}
|
||||
if !s(&w["encouragement"]).trim().is_empty() {
|
||||
alt["encouragements"] = button(&w["encouragement"]);
|
||||
}
|
||||
@@ -2452,6 +2476,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
"stage_words": all_stages(n).iter().map(|s| (s.clone(), json!(stage_text(n, s)))).collect::<Map<_, _>>(),
|
||||
});
|
||||
if let Some(group) = &n.enters_as { need["enters_as"] = json!(group); }
|
||||
if n.paused { need["paused"] = json!(true); }
|
||||
let others: BTreeSet<&String> = n.moves_by.values().flatten().filter(|m| *m != SUBMITTER && **m != n.handled_by).collect();
|
||||
if !others.is_empty() { need["also_moved_by"] = json!(others); }
|
||||
need
|
||||
@@ -2525,7 +2550,7 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -
|
||||
context.extend(
|
||||
plan.needs
|
||||
.iter()
|
||||
.map(|n| format!("- {}: {} - wants: {}. Handled by {}. Stages: {}. Lands in `{}`; its form collects: {}.", n.id, n.who, n.wants, n.handled_by, n.stages.join(" -> "), n.lands_in, n.collects.iter().map(|f| format!("{{{}}}{}{}", f.name, if f.kind == "textarea" { " (long)" } else { "" }, if f.optional { " (optional)" } else { "" })).collect::<Vec<_>>().join(", "))),
|
||||
.map(|n| format!("- {}{}: {} - wants: {}. Handled by {}. Stages: {}. Lands in `{}`; its form collects: {}.", n.id, if n.paused { " (PAUSED: not taking new cases right now; its card shows no form and its button does not press, and `paused` is the sentence after its description that says so)" } else { "" }, n.who, n.wants, n.handled_by, n.stages.join(" -> "), n.lands_in, n.collects.iter().map(|f| format!("{{{}}}{}{}", f.name, if f.kind == "textarea" { " (long)" } else { "" }, if f.optional { " (optional)" } else { "" })).collect::<Vec<_>>().join(", "))),
|
||||
);
|
||||
format!(
|
||||
"You write the words for one page of a website for this business.\n\nBusiness: {}\nAbout: {}\nContact: {} <{}>\nLanguage to write in: {}\n\nEveryone the site serves:\n{}\n\nThe page: {path}\nIt is {purpose}\n\nBelow is the page as JSON. Every value is a slot that says what to write there. Return the same JSON with every slot replaced by your words. Keep every key exactly as it is. A slot left as it is counts as no answer.\n\nRules for the words:\n- `question` is a question the reader could ask themselves.\n- A heading says what the reader gets, in the reader's own words, and must work without its description.\n- Lead with what a thing enables. No negations in headings. No trade jargon the reader would not use.\n- Descriptions are one or two short sentences. Labels and buttons are a few words.\n- The button says what pressing it does; the encouragement says what happens next or how little it takes; the form's guidance line says what to have ready. Placeholders are example answers in the reader's words.\n- An icon is one name from this list, chosen for what the reader is doing, or empty: {}\n- Write the way people in this trade and country actually speak.{voice}\n\n{}\n\nReply with the JSON object only.",
|
||||
@@ -2682,7 +2707,7 @@ mod tests {
|
||||
stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable),
|
||||
moves_by: BTreeMap::new(), watched_by: vec![],
|
||||
events: BTreeMap::new(), attended_by: None, mails: BTreeMap::new(), stages_text: BTreeMap::new(), turned_down_from: vec![], reached_from: BTreeMap::new(), requires: String::new(), followed_by: vec![],
|
||||
grants: BTreeMap::new(), deadlines: BTreeMap::new(), escalate: BTreeMap::new(), public_states: vec![], presents: false,
|
||||
grants: BTreeMap::new(), deadlines: BTreeMap::new(), escalate: BTreeMap::new(), public_states: vec![], presents: false, paused: false,
|
||||
collects: vec![], max_fields: 3,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +191,11 @@ rest. Each is one line in the plan, and the builder does the wiring:
|
||||
of them, as cards: the people who work there, entered from inside
|
||||
(`enters_as`), each with a `picture` field that shows as their
|
||||
portrait.
|
||||
- **Pause what you cannot take on.** `paused: true` on a need keeps
|
||||
its card on the page, saying what the business does and that it is
|
||||
not taking new ones right now, with no form and a button that does
|
||||
not press. Take the line out to open again; the desk and every case
|
||||
under way are untouched.
|
||||
- **Show the work.** `slideshow` on the plan is portal's own block,
|
||||
carried onto the front door as written: a strip of pictures under
|
||||
the question, each with its caption.
|
||||
|
||||
Reference in New Issue
Block a user