feat(api): op-login 游戏内审批先展示目标账号、邮箱与发起来源,须输入账户名确认,velocity 显示审批卡片
This commit is contained in:
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 : "";
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user