atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
53bce0d930007b21fb1f130865979bbd959edcc5
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git

Blame: src/http/run.rs

782069860236atagen2026-10-10 12:44 UTC1
//! Plumbing shared by every resource under `/runs/{id}`.
782069860236atagen2026-10-10 12:44 UTC2
782069860236atagen2026-10-10 12:44 UTC3
use axum::{
782069860236atagen2026-10-10 12:44 UTC4
    http::{HeaderName, HeaderValue, StatusCode, header},
782069860236atagen2026-10-10 12:44 UTC5
    response::{IntoResponse, Response},
782069860236atagen2026-10-10 12:44 UTC6
};
782069860236atagen2026-10-10 12:44 UTC7
use hyper_util::rt::TokioIo;
782069860236atagen2026-10-10 12:44 UTC8
use tokio::io::AsyncWriteExt;
782069860236atagen2026-10-10 12:44 UTC9
159f0998263batagen2026-10-12 11:01 UTC10
use super::{
4f54028cba69atagen2026-10-14 11:45 UTC11
    AppState, ctx::Ctx, links, meta::GRAVEYARD_PATH, middleware::Compress, registry::Lock,
4f54028cba69atagen2026-10-14 11:45 UTC12
    resource::Resource, shame,
159f0998263batagen2026-10-12 11:01 UTC13
};
782069860236atagen2026-10-10 12:44 UTC14
use crate::{
0fdc47364c8catagen2026-10-11 04:45 UTC15
    game::{
8b8e83329a1datagen2026-10-12 12:01 UTC16
        Cause, Gauges, Run, Shown,
8b8e83329a1datagen2026-10-12 12:01 UTC17
        balance::{FRICTION_MAX, SUSPICION_LIMIT},
0fdc47364c8catagen2026-10-11 04:45 UTC18
    },
782069860236atagen2026-10-10 12:44 UTC19
    headers::{
624fe9c09c8eatagen2026-10-14 12:11 UTC20
        X_CASH, X_DANGER, X_EQ, X_FLOOR, X_FLOOR_OVER, X_FLOOR_TURN, X_FORESKIN, X_FRICTION,
624fe9c09c8eatagen2026-10-14 12:11 UTC21
        X_GAINS_ARRIVED, X_GIRTH, X_LENGTH, X_OBSERVED_BY, X_RUN_OVER, X_SELF_REPORTED_LENGTH,
624fe9c09c8eatagen2026-10-14 12:11 UTC22
        X_SHAME, X_STAMINA, X_SUSPICION, X_TURN,
782069860236atagen2026-10-10 12:44 UTC23
    },
4f54028cba69atagen2026-10-14 11:45 UTC24
    link::Link,
ef7d6dee5976atagen2026-10-10 13:23 UTC25
    locale::Locale,
782069860236atagen2026-10-10 12:44 UTC26
    problem::{PROBLEM_BASE, Problem},
159f0998263batagen2026-10-12 11:01 UTC27
    records::Record,
782069860236atagen2026-10-10 12:44 UTC28
    secret::Secret,
782069860236atagen2026-10-10 12:44 UTC29
};
782069860236atagen2026-10-10 12:44 UTC30
782069860236atagen2026-10-10 12:44 UTC31
/// Which household hazards stand between a request and the run.
782069860236atagen2026-10-10 12:44 UTC32
#[derive(Clone, Copy, PartialEq, Eq)]
782069860236atagen2026-10-10 12:44 UTC33
pub enum Scope {
782069860236atagen2026-10-10 12:44 UTC34
    Home,
782069860236atagen2026-10-10 12:44 UTC35
    Pockets,
782069860236atagen2026-10-10 12:44 UTC36
}
782069860236atagen2026-10-10 12:44 UTC37
782069860236atagen2026-10-10 12:44 UTC38
pub struct RunResource {
782069860236atagen2026-10-10 12:44 UTC39
    pub scope: Scope,
782069860236atagen2026-10-10 12:44 UTC40
    pub resource: Resource<Run>,
782069860236atagen2026-10-10 12:44 UTC41
}
782069860236atagen2026-10-10 12:44 UTC42
782069860236atagen2026-10-10 12:44 UTC43
/// What the server writes over the connection after a `101`.
782069860236atagen2026-10-10 12:44 UTC44
#[derive(Clone)]
782069860236atagen2026-10-10 12:44 UTC45
pub struct Greeting(pub String);
782069860236atagen2026-10-10 12:44 UTC46
8b8e83329a1datagen2026-10-12 12:01 UTC47
pub fn records_unavailable(error: &rusqlite::Error) -> Response {
8b8e83329a1datagen2026-10-12 12:01 UTC48
    Problem::new(
8b8e83329a1datagen2026-10-12 12:01 UTC49
        StatusCode::SERVICE_UNAVAILABLE,
8b8e83329a1datagen2026-10-12 12:01 UTC50
        "records-unavailable",
8b8e83329a1datagen2026-10-12 12:01 UTC51
        "The records are unavailable. Nothing has been forgotten, it is only out of reach.",
8b8e83329a1datagen2026-10-12 12:01 UTC52
    )
8b8e83329a1datagen2026-10-12 12:01 UTC53
    .with("error", error.to_string())
8b8e83329a1datagen2026-10-12 12:01 UTC54
    .into_response()
8b8e83329a1datagen2026-10-12 12:01 UTC55
}
8b8e83329a1datagen2026-10-12 12:01 UTC56
782069860236atagen2026-10-10 12:44 UTC57
pub fn serve(state: &AppState, mut ctx: Ctx, target: &RunResource) -> Response {
159f0998263batagen2026-10-12 11:01 UTC58
    let id = ctx.param("id").to_owned();
159f0998263batagen2026-10-12 11:01 UTC59
    let Some(slot) = state.runs.get(&id) else {
159f0998263batagen2026-10-12 11:01 UTC60
        return match state.records.find(&id) {
8b8e83329a1datagen2026-10-12 12:01 UTC61
            Ok(Some(Record {
8b8e83329a1datagen2026-10-12 12:01 UTC62
                cause: Some(cause),
159f0998263batagen2026-10-12 11:01 UTC63
                turn,
159f0998263batagen2026-10-12 11:01 UTC64
                ..
8b8e83329a1datagen2026-10-12 12:01 UTC65
            })) => gone(cause.slug(), turn),
8b8e83329a1datagen2026-10-12 12:01 UTC66
            Ok(_) => Problem::new(
159f0998263batagen2026-10-12 11:01 UTC67
                StatusCode::NOT_FOUND,
159f0998263batagen2026-10-12 11:01 UTC68
                "no-such-run",
159f0998263batagen2026-10-12 11:01 UTC69
                "There is no such run. There may never have been.",
159f0998263batagen2026-10-12 11:01 UTC70
            )
159f0998263batagen2026-10-12 11:01 UTC71
            .into_response(),
8b8e83329a1datagen2026-10-12 12:01 UTC72
            Err(error) => records_unavailable(&error),
159f0998263batagen2026-10-12 11:01 UTC73
        };
782069860236atagen2026-10-10 12:44 UTC74
    };
8b8e83329a1datagen2026-10-12 12:01 UTC75
    let (response, record) = match Lock::take(&slot) {
8b8e83329a1datagen2026-10-12 12:01 UTC76
        Lock::Poisoned(mut run) => {
8b8e83329a1datagen2026-10-12 12:01 UTC77
            run.lose();
8b8e83329a1datagen2026-10-12 12:01 UTC78
            let response = gone(Cause::Lost.slug(), run.turn);
8b8e83329a1datagen2026-10-12 12:01 UTC79
            (response, Some(Record::of(&run)))
8b8e83329a1datagen2026-10-12 12:01 UTC80
        }
8b8e83329a1datagen2026-10-12 12:01 UTC81
        Lock::Held(mut run) => {
8b8e83329a1datagen2026-10-12 12:01 UTC82
            if let Some(ending) = &run.ended {
8b8e83329a1datagen2026-10-12 12:01 UTC83
                return gone(ending.cause.slug(), ending.turn);
8b8e83329a1datagen2026-10-12 12:01 UTC84
            }
8b8e83329a1datagen2026-10-12 12:01 UTC85
            run.seen(ctx.now());
6f7c0593b2caatagen2026-10-14 09:03 UTC86
            let mut response = gate(&mut run, &ctx, target.scope)
8b8e83329a1datagen2026-10-12 12:01 UTC87
                .unwrap_or_else(|| target.resource.dispatch(&mut run, &ctx));
6f7c0593b2caatagen2026-10-14 09:03 UTC88
            if let Some(ending) = &run.ended {
6f7c0593b2caatagen2026-10-14 09:03 UTC89
                response
6f7c0593b2caatagen2026-10-14 09:03 UTC90
                    .headers_mut()
6f7c0593b2caatagen2026-10-14 09:03 UTC91
                    .insert(X_RUN_OVER, HeaderValue::from_static(ending.cause.slug()));
6f7c0593b2caatagen2026-10-14 09:03 UTC92
            }
8b8e83329a1datagen2026-10-12 12:01 UTC93
            let response = finish(&mut run, &ctx, &state.config.secret, response);
8b8e83329a1datagen2026-10-12 12:01 UTC94
            let record = run.status().finished().then(|| Record::of(&run));
8b8e83329a1datagen2026-10-12 12:01 UTC95
            (response, record)
8b8e83329a1datagen2026-10-12 12:01 UTC96
        }
8b8e83329a1datagen2026-10-12 12:01 UTC97
    };
8b8e83329a1datagen2026-10-12 12:01 UTC98
    if let Some(record) = record {
8b8e83329a1datagen2026-10-12 12:01 UTC99
        if let Err(error) = state.records.save(&record) {
8b8e83329a1datagen2026-10-12 12:01 UTC100
            return records_unavailable(&error);
8b8e83329a1datagen2026-10-12 12:01 UTC101
        }
8b8e83329a1datagen2026-10-12 12:01 UTC102
        if record.cause.is_some() {
8b8e83329a1datagen2026-10-12 12:01 UTC103
            state.runs.remove(&id);
8b8e83329a1datagen2026-10-12 12:01 UTC104
        }
159f0998263batagen2026-10-12 11:01 UTC105
    }
782069860236atagen2026-10-10 12:44 UTC106
    greet(response, &mut ctx)
782069860236atagen2026-10-10 12:44 UTC107
}
782069860236atagen2026-10-10 12:44 UTC108
159f0998263batagen2026-10-12 11:01 UTC109
fn gone(cause: &str, turn: u32) -> Response {
159f0998263batagen2026-10-12 11:01 UTC110
    Problem::new(StatusCode::GONE, "run-ended", "This run has ended.")
159f0998263batagen2026-10-12 11:01 UTC111
        .with("cause", format!("{PROBLEM_BASE}{cause}"))
159f0998263batagen2026-10-12 11:01 UTC112
        .with("turn", turn)
4f54028cba69atagen2026-10-14 11:45 UTC113
        .with("graveyard", GRAVEYARD_PATH)
6f7c0593b2caatagen2026-10-14 09:03 UTC114
        .header(X_RUN_OVER, cause)
159f0998263batagen2026-10-12 11:01 UTC115
        .into_response()
159f0998263batagen2026-10-12 11:01 UTC116
}
159f0998263batagen2026-10-12 11:01 UTC117
782069860236atagen2026-10-10 12:44 UTC118
fn gate(run: &mut Run, ctx: &Ctx, scope: Scope) -> Option<Response> {
782069860236atagen2026-10-10 12:44 UTC119
    if scope == Scope::Pockets {
782069860236atagen2026-10-10 12:44 UTC120
        return None;
782069860236atagen2026-10-10 12:44 UTC121
    }
8b8e83329a1datagen2026-10-12 12:01 UTC122
    if run.needs_portal() {
782069860236atagen2026-10-10 12:44 UTC123
        let portal = run.path("/portal");
782069860236atagen2026-10-10 12:44 UTC124
        return Some(
782069860236atagen2026-10-10 12:44 UTC125
            Problem::new(
782069860236atagen2026-10-10 12:44 UTC126
                StatusCode::NETWORK_AUTHENTICATION_REQUIRED,
782069860236atagen2026-10-10 12:44 UTC127
                "captive-portal",
782069860236atagen2026-10-10 12:44 UTC128
                "The network requires authentication. The hotel would like you to agree to some terms.",
782069860236atagen2026-10-10 12:44 UTC129
            )
782069860236atagen2026-10-10 12:44 UTC130
            .with("portal", portal.as_str())
4f54028cba69atagen2026-10-14 11:45 UTC131
            .header(header::LINK, Link::jelq(&portal, "portal").to_string())
782069860236atagen2026-10-10 12:44 UTC132
            .into_response(),
782069860236atagen2026-10-10 12:44 UTC133
        );
782069860236atagen2026-10-10 12:44 UTC134
    }
ef7d6dee5976atagen2026-10-10 13:23 UTC135
    let authorization = ctx
ef7d6dee5976atagen2026-10-10 13:23 UTC136
        .header(header::AUTHORIZATION)
ef7d6dee5976atagen2026-10-10 13:23 UTC137
        .map(|text| Locale::negotiate(ctx.header(header::ACCEPT_LANGUAGE)).canonicalise(text));
ef7d6dee5976atagen2026-10-10 13:23 UTC138
    run.answer_door(authorization.as_deref())
782069860236atagen2026-10-10 12:44 UTC139
        .err()
782069860236atagen2026-10-10 12:44 UTC140
        .map(IntoResponse::into_response)
782069860236atagen2026-10-10 12:44 UTC141
}
782069860236atagen2026-10-10 12:44 UTC142
8b8e83329a1datagen2026-10-12 12:01 UTC143
fn stats(gauges: &Gauges, shame: u32) -> Vec<(HeaderName, String)> {
8b8e83329a1datagen2026-10-12 12:01 UTC144
    let mut stats = vec![
8b8e83329a1datagen2026-10-12 12:01 UTC145
        match gauges.length {
8b8e83329a1datagen2026-10-12 12:01 UTC146
            Shown::Measured(length) => (X_LENGTH, format!("{length:.2}")),
8b8e83329a1datagen2026-10-12 12:01 UTC147
            Shown::SelfReported(length) => (X_SELF_REPORTED_LENGTH, format!("{length:.2}")),
8b8e83329a1datagen2026-10-12 12:01 UTC148
        },
8b8e83329a1datagen2026-10-12 12:01 UTC149
        (X_GIRTH, format!("{:.2}", gauges.girth)),
d604371299fbatagen2026-10-10 13:54 UTC150
        (
8b8e83329a1datagen2026-10-12 12:01 UTC151
            X_FORESKIN,
8b8e83329a1datagen2026-10-12 12:01 UTC152
            gauges.foreskin.map_or("gone".into(), |f| format!("{f:.2}")),
d604371299fbatagen2026-10-10 13:54 UTC153
        ),
8b8e83329a1datagen2026-10-12 12:01 UTC154
        (X_FRICTION, format!("{}/{FRICTION_MAX}", gauges.friction)),
e028a4184d00atagen2026-10-14 10:49 UTC155
        (X_EQ, gauges.arousal.name().into()),
d604371299fbatagen2026-10-10 13:54 UTC156
        (
8b8e83329a1datagen2026-10-12 12:01 UTC157
            X_STAMINA,
8b8e83329a1datagen2026-10-12 12:01 UTC158
            format!("{}/{}", gauges.stamina, gauges.max_stamina),
d604371299fbatagen2026-10-10 13:54 UTC159
        ),
782069860236atagen2026-10-10 12:44 UTC160
        (
8b8e83329a1datagen2026-10-12 12:01 UTC161
            X_SUSPICION,
8b8e83329a1datagen2026-10-12 12:01 UTC162
            format!("{}/{SUSPICION_LIMIT}", gauges.suspicion),
782069860236atagen2026-10-10 12:44 UTC163
        ),
782069860236atagen2026-10-10 12:44 UTC164
        (X_SHAME, shame.to_string()),
8b8e83329a1datagen2026-10-12 12:01 UTC165
        (X_CASH, gauges.cash.to_string()),
8b8e83329a1datagen2026-10-12 12:01 UTC166
        (X_FLOOR, gauges.floor.to_string()),
35d571ccadc4atagen2026-10-14 11:33 UTC167
        (X_TURN, gauges.turn.to_string()),
35d571ccadc4atagen2026-10-14 11:33 UTC168
        (
35d571ccadc4atagen2026-10-14 11:33 UTC169
            X_FLOOR_TURN,
35d571ccadc4atagen2026-10-14 11:33 UTC170
            gauges
35d571ccadc4atagen2026-10-14 11:33 UTC171
                .floor_limit
35d571ccadc4atagen2026-10-14 11:33 UTC172
                .map_or(gauges.floor_turns.to_string(), |limit| {
35d571ccadc4atagen2026-10-14 11:33 UTC173
                    format!("{}/{limit}", gauges.floor_turns)
35d571ccadc4atagen2026-10-14 11:33 UTC174
                }),
35d571ccadc4atagen2026-10-14 11:33 UTC175
        ),
782069860236atagen2026-10-10 12:44 UTC176
    ];
624fe9c09c8eatagen2026-10-14 12:11 UTC177
    if gauges.floor_over {
624fe9c09c8eatagen2026-10-14 12:11 UTC178
        stats.push((X_FLOOR_OVER, "?1".into()));
624fe9c09c8eatagen2026-10-14 12:11 UTC179
    }
f3a1913c1299atagen2026-10-14 11:42 UTC180
    if !gauges.dangers.is_empty() {
f3a1913c1299atagen2026-10-14 11:42 UTC181
        let dangers: Vec<String> = gauges
f3a1913c1299atagen2026-10-14 11:42 UTC182
            .dangers
f3a1913c1299atagen2026-10-14 11:42 UTC183
            .iter()
f3a1913c1299atagen2026-10-14 11:42 UTC184
            .map(|(gauge, danger)| format!("{gauge}={}", danger.name()))
f3a1913c1299atagen2026-10-14 11:42 UTC185
            .collect();
f3a1913c1299atagen2026-10-14 11:42 UTC186
        stats.push((X_DANGER, dangers.join(", ")));
f3a1913c1299atagen2026-10-14 11:42 UTC187
    }
8b8e83329a1datagen2026-10-12 12:01 UTC188
    if let Some(observer) = gauges.observed {
782069860236atagen2026-10-10 12:44 UTC189
        stats.push((X_OBSERVED_BY, observer.name().into()));
782069860236atagen2026-10-10 12:44 UTC190
    }
886159fd0fe7atagen2026-10-13 12:48 UTC191
    if let Some(challenge) = &gauges.mum_challenge {
9683736a2c5eatagen2026-10-10 13:15 UTC192
        stats.push((header::VIA, "1.1 mum".into()));
9683736a2c5eatagen2026-10-10 13:15 UTC193
        stats.push((header::VARY, "Mum".into()));
886159fd0fe7atagen2026-10-13 12:48 UTC194
        stats.push((header::WWW_AUTHENTICATE, challenge.clone()));
782069860236atagen2026-10-10 12:44 UTC195
    }
fcc6cf6d5edcatagen2026-10-13 11:31 UTC196
    if let Some(arrived) = &gauges.arrived {
fcc6cf6d5edcatagen2026-10-13 11:31 UTC197
        let mut gains = format!("length={:.3}, girth={:.3}", arrived.length, arrived.girth);
fcc6cf6d5edcatagen2026-10-13 11:31 UTC198
        if let Some(foreskin) = arrived.foreskin {
fcc6cf6d5edcatagen2026-10-13 11:31 UTC199
            gains.push_str(&format!(", foreskin={foreskin:.3}"));
fcc6cf6d5edcatagen2026-10-13 11:31 UTC200
        }
fcc6cf6d5edcatagen2026-10-13 11:31 UTC201
        stats.push((X_GAINS_ARRIVED, gains));
fcc6cf6d5edcatagen2026-10-13 11:31 UTC202
    }
782069860236atagen2026-10-10 12:44 UTC203
    // RFC 9111 obsoleted Warning, which is why nobody heeds it.
8b8e83329a1datagen2026-10-12 12:01 UTC204
    for warning in &gauges.warnings {
8b8e83329a1datagen2026-10-12 12:01 UTC205
        stats.push((header::WARNING, format!("199 - \"{warning}\"")));
782069860236atagen2026-10-10 12:44 UTC206
    }
8b8e83329a1datagen2026-10-12 12:01 UTC207
    stats
8b8e83329a1datagen2026-10-12 12:01 UTC208
}
8b8e83329a1datagen2026-10-12 12:01 UTC209
8b8e83329a1datagen2026-10-12 12:01 UTC210
pub fn finish(run: &mut Run, ctx: &Ctx, secret: &Secret, mut response: Response) -> Response {
8b8e83329a1datagen2026-10-12 12:01 UTC211
    let status = response.status();
8b8e83329a1datagen2026-10-12 12:01 UTC212
    if status == StatusCode::NOT_MODIFIED {
8b8e83329a1datagen2026-10-12 12:01 UTC213
        return response;
782069860236atagen2026-10-10 12:44 UTC214
    }
8b8e83329a1datagen2026-10-12 12:01 UTC215
    let (shame, shame_cookie) = shame::settle(run, ctx, secret);
8b8e83329a1datagen2026-10-12 12:01 UTC216
    let gauges = run.gauges();
8b8e83329a1datagen2026-10-12 12:01 UTC217
    let link = links::header(run);
782069860236atagen2026-10-10 12:44 UTC218
    let headers = response.headers_mut();
8b8e83329a1datagen2026-10-12 12:01 UTC219
    for (name, value) in stats(&gauges, shame) {
782069860236atagen2026-10-10 12:44 UTC220
        if let Ok(value) = HeaderValue::from_str(&value) {
782069860236atagen2026-10-10 12:44 UTC221
            headers.append(name, value);
782069860236atagen2026-10-10 12:44 UTC222
        }
782069860236atagen2026-10-10 12:44 UTC223
    }
8b8e83329a1datagen2026-10-12 12:01 UTC224
    if let Ok(link) = HeaderValue::from_str(&link) {
8b8e83329a1datagen2026-10-12 12:01 UTC225
        headers.append(header::LINK, link);
8b8e83329a1datagen2026-10-12 12:01 UTC226
    }
8b8e83329a1datagen2026-10-12 12:01 UTC227
    headers.append(header::SET_COOKIE, shame_cookie);
8b8e83329a1datagen2026-10-12 12:01 UTC228
    if gauges.ruler {
8b8e83329a1datagen2026-10-12 12:01 UTC229
        headers.append(header::SET_COOKIE, shame::ruler_cookie(secret));
8b8e83329a1datagen2026-10-12 12:01 UTC230
    }
782069860236atagen2026-10-10 12:44 UTC231
    let bodiless = status.is_informational() || status == StatusCode::NO_CONTENT;
8b8e83329a1datagen2026-10-12 12:01 UTC232
    if gauges.ring && !bodiless {
782069860236atagen2026-10-10 12:44 UTC233
        response.extensions_mut().insert(Compress);
782069860236atagen2026-10-10 12:44 UTC234
    }
782069860236atagen2026-10-10 12:44 UTC235
    response
782069860236atagen2026-10-10 12:44 UTC236
}
782069860236atagen2026-10-10 12:44 UTC237
782069860236atagen2026-10-10 12:44 UTC238
fn greet(response: Response, ctx: &mut Ctx) -> Response {
782069860236atagen2026-10-10 12:44 UTC239
    if response.status() != StatusCode::SWITCHING_PROTOCOLS {
782069860236atagen2026-10-10 12:44 UTC240
        return response;
782069860236atagen2026-10-10 12:44 UTC241
    }
782069860236atagen2026-10-10 12:44 UTC242
    let (Some(Greeting(text)), Some(upgrade)) = (
782069860236atagen2026-10-10 12:44 UTC243
        response.extensions().get::<Greeting>().cloned(),
8b8e83329a1datagen2026-10-12 12:01 UTC244
        ctx.take_upgrade(),
782069860236atagen2026-10-10 12:44 UTC245
    ) else {
782069860236atagen2026-10-10 12:44 UTC246
        return response;
782069860236atagen2026-10-10 12:44 UTC247
    };
782069860236atagen2026-10-10 12:44 UTC248
    tokio::spawn(async move {
782069860236atagen2026-10-10 12:44 UTC249
        if let Ok(upgraded) = upgrade.await {
782069860236atagen2026-10-10 12:44 UTC250
            let mut io = TokioIo::new(upgraded);
782069860236atagen2026-10-10 12:44 UTC251
            let _ = io.write_all(text.as_bytes()).await;
782069860236atagen2026-10-10 12:44 UTC252
            let _ = io.shutdown().await;
782069860236atagen2026-10-10 12:44 UTC253
        }
782069860236atagen2026-10-10 12:44 UTC254
    });
782069860236atagen2026-10-10 12:44 UTC255
    response
782069860236atagen2026-10-10 12:44 UTC256
}