feat(api): op-login 游戏内审批先展示目标账号、邮箱与发起来源,须输入账户名确认,velocity 显示审批卡片

This commit is contained in:
Lemon-miaow committed 2026-09-25 17:02:43 +08:00
1 parent 4757353324
commit d93c1b6913
21 files changed
+1088 -176

No files matched your search

@@ -179,30 +179,49 @@ public final class FelisApiClient {
}
/**
* opLoginApprove records an in-game administrator's vouch for a pending op.console
* staff login — the second factor of the spec §B op-login door, supplied from
* Velocity's {@code /felis web op approve <code>}. It POSTs the approver's verified
* online-mode UUID to {@code POST /api/v1/internal/op-login/{id}/approve}; felis-api
* resolves that UUID to a linked account and refuses unless it is {@code role=admin}
* (403 {@code not_admin}), so this is defence in depth over Velocity's own in-game
* guard rather than the sole check. A {@code requestId} naming no live pending
* request is 404 {@code op_login_not_found}. Both arrive as branchable
* {@link LinkException}s; a 200 that does not affirm {@code approved:true} is a
* contract breach, not a refusal.
* opLoginShow reads whose op.console staff sign-in a pending request is — the first
* half of Velocity's {@code /felis web op approve <code>}, shown to the admin before
* they vouch. {@code GET /api/v1/internal/op-login/{id}?approver_uuid=<uuid>}:
* felis-api refuses unless the approver's verified UUID is linked to an admin or
* owner (403 {@code not_admin}), so a player who types the command learns nothing
* about a staff account. An id naming no live pending request is 404 {@code
* op_login_not_found}. Both arrive as branchable {@link LinkException}s.
*
* <p>{@code requestId} is interpolated into the request path. It is
* percent-encoded here, and the Velocity command also validates its charset
* before calling.
*/
public void opLoginApprove(String requestId, UUID approverUuid) throws LinkException {
public OpLoginView opLoginShow(String requestId, UUID approverUuid) throws LinkException {
Objects.requireNonNull(requestId, "requestId");
Objects.requireNonNull(approverUuid, "approverUuid");
String body = "{\"approver_uuid\":\"" + approverUuid + "\"}";
return OpLoginView.fromJson(getObject("/api/v1/internal/op-login/" + segment(requestId)
+ "?approver_uuid=" + approverUuid, 200));
}
/**
* opLoginApprove records an in-game administrator's vouch for a pending op.console
* staff login — the second factor of the spec §B op-login door, supplied from
* Velocity's {@code /felis web op approve <code> <username>}. It POSTs the approver's
* verified online-mode UUID and the account name they typed after reading
* {@link #opLoginShow} to {@code POST /api/v1/internal/op-login/{id}/approve}.
* felis-api refuses unless the UUID is linked to an admin or owner (403 {@code
* not_admin}) and unless the name is the request's account (409 {@code
* op_login_mismatch}, request left pending). An id naming no live pending request is
* 404 {@code op_login_not_found}. All arrive as branchable {@link LinkException}s; a
* 200 that does not affirm {@code approved:true} is a contract breach, not a
* refusal. Returns the approved account's username and email.
*/
public OpLoginView opLoginApprove(String requestId, UUID approverUuid, String username) throws LinkException {
Objects.requireNonNull(requestId, "requestId");
Objects.requireNonNull(approverUuid, "approverUuid");
Objects.requireNonNull(username, "username");
String body = "{\"approver_uuid\":\"" + approverUuid + "\",\"username\":" + Json.quote(username) + "}";
Map<?, ?> res = postObject("/api/v1/internal/op-login/" + segment(requestId) + "/approve", body, 200);
Object approved = res.get("approved");
if (!(approved instanceof Boolean) || !((Boolean) approved)) {
throw new LinkException(200, "bad_response", "approve returned 200 without approved=true");
}
return OpLoginView.fromJson(res);
}
/**
@@ -25,6 +25,32 @@ final class Json {
this.s = s;
}
/**
* quote renders s as a JSON string literal, for the few request bodies that carry
* text a player typed. Quotes, backslashes and control characters are escaped.
*/
static String quote(String s) {
StringBuilder b = new StringBuilder(s.length() + 2).append('"');
for (int k = 0; k < s.length(); k++) {
char c = s.charAt(k);
switch (c) {
case '"':
b.append("\\\"");
break;
case '\\':
b.append("\\\\");
break;
default:
if (c < 0x20) {
b.append(String.format("\\u%04x", (int) c));
} else {
b.append(c);
}
}
}
return b.append('"').toString();
}
/** parse reads a single JSON value from text, rejecting trailing garbage. */
static Object parse(String text) {
Json p = new Json(text);
@@ -0,0 +1,83 @@
package best.lolicon.felis.link;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.Map;
/**
* OpLoginView is a pending op.console staff sign-in as the in-game approver sees it
* ({@code GET /api/v1/internal/op-login/{id}}, spec §B op-login): whose account it is,
* the address the code went to, and when and from where the sign-in was started. The
* approve response reuses it for the account it approved, where only the username
* and email are filled.
*
* <p>{@link #fromJson(Map)} tolerates absent fields like {@link ServerView}: a
* missing string reads as "", an unparseable time as null, so a partial body never
* throws on the proxy's command thread.
*/
public final class OpLoginView {
private final String requestId;
private final String username;
private final String email;
private final String clientIp;
private final String userAgent;
private final Instant createdAt;
public OpLoginView(String requestId, String username, String email,
String clientIp, String userAgent, Instant createdAt) {
this.requestId = requestId;
this.username = username;
this.email = email;
this.clientIp = clientIp;
this.userAgent = userAgent;
this.createdAt = createdAt;
}
/** fromJson builds a view from a parsed show or approve body. */
public static OpLoginView fromJson(Map<?, ?> o) {
Instant created = null;
String at = str(o, "created_at");
if (!at.isEmpty()) {
try {
created = Instant.parse(at);
} catch (DateTimeParseException ignored) {
// Leave it null; the card then omits the age line.
}
}
return new OpLoginView(str(o, "request_id"), str(o, "username"), str(o, "email"),
str(o, "client_ip"), str(o, "user_agent"), created);
}
public String requestId() {
return requestId;
}
/** username is the Felis account the sign-in is for; the approver retypes it. */
public String username() {
return username;
}
public String email() {
return email;
}
/** clientIp is where the sign-in was started; "" when the API did not record one. */
public String clientIp() {
return clientIp;
}
/** userAgent is the browser that started the sign-in; may be "". */
public String userAgent() {
return userAgent;
}
/** createdAt is when the sign-in was started, or null when absent. */
public Instant createdAt() {
return createdAt;
}
private static String str(Map<?, ?> o, String key) {
Object v = o.get(key);
return v instanceof String ? (String) v : "";
}
}