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 : "";
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import com.sun.net.httpserver.HttpServer;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
@@ -28,20 +29,35 @@ public final class FelisApiClientTest {
|
||||
|
||||
private static int checks;
|
||||
private static final List<String> seen = new CopyOnWriteArrayList<>();
|
||||
private static final List<String> bodies = new CopyOnWriteArrayList<>();
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
HttpServer stub = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
stub.createContext("/", exchange -> {
|
||||
seen.add(exchange.getRequestMethod() + " " + exchange.getRequestURI().getRawPath());
|
||||
String query = exchange.getRequestURI().getRawQuery();
|
||||
seen.add(exchange.getRequestMethod() + " " + exchange.getRequestURI().getRawPath()
|
||||
+ (query == null ? "" : "?" + query));
|
||||
bodies.add(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
|
||||
String path = exchange.getRequestURI().getRawPath();
|
||||
String body;
|
||||
int status;
|
||||
if (path.endsWith("/wake")) {
|
||||
status = 202;
|
||||
body = "{\"name\":\"alpha\",\"phase\":\"Starting\",\"ready\":false}";
|
||||
} else if (path.equals("/api/v1/internal/op-login/mismatch/approve")) {
|
||||
status = 409;
|
||||
body = "{\"error\":{\"code\":\"op_login_mismatch\",\"message\":\"that operator login is for a different account\"}}";
|
||||
} else if (path.equals("/api/v1/internal/op-login/half/approve")) {
|
||||
status = 200;
|
||||
body = "{\"username\":\"op\"}";
|
||||
} else if (path.endsWith("/approve")) {
|
||||
status = 200;
|
||||
body = "{\"approved\":true}";
|
||||
body = "{\"approved\":true,\"username\":\"op\",\"email\":\"[email protected]\"}";
|
||||
} else if (path.startsWith("/api/v1/internal/op-login/")) {
|
||||
status = 200;
|
||||
body = "{\"request_id\":\"0123abcd\",\"username\":\"op\",\"email\":\"[email protected]\","
|
||||
+ "\"client_ip\":\"203.0.113.9\",\"user_agent\":\"Firefox/140.0\","
|
||||
+ "\"created_at\":\"2023-11-14T22:13:20Z\",\"expires_at\":\"2023-11-14T22:23:20Z\"}";
|
||||
} else {
|
||||
status = 200;
|
||||
body = "{\"name\":\"alpha\",\"phase\":\"Running\",\"ready\":true,\"claimable\":false}";
|
||||
@@ -60,6 +76,8 @@ public final class FelisApiClientTest {
|
||||
wellFormedNamesReachTheirRoute(api);
|
||||
pathBendingNamesNeverLeaveTheClient(api);
|
||||
opaqueSegmentsArePercentEncoded(api);
|
||||
opLoginShowNamesTheAccount(api);
|
||||
opLoginApproveSendsTheTypedName(api);
|
||||
} finally {
|
||||
stub.stop(0);
|
||||
}
|
||||
@@ -113,8 +131,53 @@ public final class FelisApiClientTest {
|
||||
expectRefused("dotdot", () -> FelisApiClient.segment(".."));
|
||||
|
||||
seen.clear();
|
||||
api.opLoginApprove("0123abcd", UUID.fromString("00000000-0000-0000-0000-000000000003"));
|
||||
assertEq("approve route", List.of("POST /api/v1/internal/op-login/0123abcd/approve"), seen);
|
||||
api.opLoginApprove("0123abcd", UUID.fromString("00000000-0000-0000-0000-000000000003"), "op");
|
||||
api.opLoginShow("a/b", UUID.fromString("00000000-0000-0000-0000-000000000003"));
|
||||
assertEq("op-login routes", List.of(
|
||||
"POST /api/v1/internal/op-login/0123abcd/approve",
|
||||
"GET /api/v1/internal/op-login/a%2Fb?approver_uuid=00000000-0000-0000-0000-000000000003"), seen);
|
||||
}
|
||||
|
||||
// The in-game card is built from this view, so every field the admin reads has to
|
||||
// come through.
|
||||
private static void opLoginShowNamesTheAccount(FelisApiClient api) throws LinkException {
|
||||
seen.clear();
|
||||
OpLoginView v = api.opLoginShow("0123abcd", UUID.fromString("00000000-0000-0000-0000-000000000004"));
|
||||
assertEq("show route", List.of(
|
||||
"GET /api/v1/internal/op-login/0123abcd?approver_uuid=00000000-0000-0000-0000-000000000004"), seen);
|
||||
assertEq("show request_id", "0123abcd", v.requestId());
|
||||
assertEq("show username", "op", v.username());
|
||||
assertEq("show email", "[email protected]", v.email());
|
||||
assertEq("show client_ip", "203.0.113.9", v.clientIp());
|
||||
assertEq("show user_agent", "Firefox/140.0", v.userAgent());
|
||||
assertEq("show created_at", Instant.parse("2023-11-14T22:13:20Z"), v.createdAt());
|
||||
}
|
||||
|
||||
// The typed name is player input, so it has to arrive as one JSON string however
|
||||
// it is spelled; the approved account comes back for the confirmation line.
|
||||
private static void opLoginApproveSendsTheTypedName(FelisApiClient api) throws LinkException {
|
||||
UUID approver = UUID.fromString("00000000-0000-0000-0000-000000000005");
|
||||
bodies.clear();
|
||||
OpLoginView v = api.opLoginApprove("0123abcd", approver, "o\"p\\");
|
||||
assertEq("approve body", List.of(
|
||||
"{\"approver_uuid\":\"00000000-0000-0000-0000-000000000005\",\"username\":\"o\\\"p\\\\\"}"), bodies);
|
||||
assertEq("approved username", "op", v.username());
|
||||
assertEq("approved email", "[email protected]", v.email());
|
||||
|
||||
try {
|
||||
api.opLoginApprove("mismatch", approver, "alice");
|
||||
throw new AssertionError("a 409 approve was not refused");
|
||||
} catch (LinkException e) {
|
||||
assertEq("mismatch status", 409, e.statusCode());
|
||||
assertEq("mismatch code", "op_login_mismatch", e.errorCode());
|
||||
}
|
||||
try {
|
||||
api.opLoginApprove("half", approver, "op");
|
||||
throw new AssertionError("a 200 without approved=true was accepted");
|
||||
} catch (LinkException e) {
|
||||
assertEq("half status", 200, e.statusCode());
|
||||
assertEq("half code", "bad_response", e.errorCode());
|
||||
}
|
||||
}
|
||||
|
||||
// ---- harness ----
|
||||
|
||||
+13
-2
@@ -11,8 +11,10 @@
|
||||
# felis-api request path, only the lobby and the login gate may drive
|
||||
# felis:control (and each only with its own frames), the link-status outage
|
||||
# fallback fails closed outside its window, /invite prompts cannot double-fire
|
||||
# or outlive their TTL, and the invite card really is a green/red clickable
|
||||
# prompt. InviteCardTest needs the adventure jars the velocity plugin compiles
|
||||
# or outlive their TTL, the invite card really is a green/red clickable
|
||||
# prompt, and the op-login approval card names the account and leaves its
|
||||
# name for the admin to type. InviteCardTest and OpApprovalCardTest need the
|
||||
# adventure jars the velocity plugin compiles
|
||||
# against; they are fetched from Maven Central below, pinned by version and
|
||||
# checked by digest (a test run against silently-substituted bytes is not a
|
||||
# test of what we ship).
|
||||
@@ -97,6 +99,15 @@ javac -cp "$adventure_api:$adventure_key:$examination_api" -d "$work/card-classe
|
||||
java -cp "$work/card-classes:$adventure_api:$adventure_key:$examination_api" \
|
||||
best.lolicon.felis.velocity.InviteCardTest
|
||||
|
||||
echo "==> OpApprovalCardTest (/felis web op approve card, velocity)"
|
||||
mkdir -p "$work/opcard-classes"
|
||||
javac -cp "$adventure_api:$adventure_key:$examination_api" -d "$work/opcard-classes" \
|
||||
plugins/shared/src/main/java/best/lolicon/felis/link/*.java \
|
||||
plugins/velocity/src/main/java/best/lolicon/felis/velocity/OpApprovalCard.java \
|
||||
plugins/velocity/test/best/lolicon/felis/velocity/OpApprovalCardTest.java
|
||||
java -cp "$work/opcard-classes:$adventure_api:$adventure_key:$examination_api" \
|
||||
best.lolicon.felis.velocity.OpApprovalCardTest
|
||||
|
||||
# --- 2. production compile gates ------------------------------------------------
|
||||
|
||||
for module in velocity paper; do
|
||||
|
||||
+57
-18
@@ -4,6 +4,7 @@ import best.lolicon.felis.link.FelisApiClient;
|
||||
import best.lolicon.felis.link.LinkClient;
|
||||
import best.lolicon.felis.link.LinkCode;
|
||||
import best.lolicon.felis.link.LinkException;
|
||||
import best.lolicon.felis.link.OpLoginView;
|
||||
import best.lolicon.felis.link.ServerView;
|
||||
|
||||
import com.google.inject.Inject;
|
||||
@@ -28,6 +29,7 @@ import org.slf4j.Logger;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
@@ -291,7 +293,8 @@ public final class FelisVelocityPlugin {
|
||||
// /felis claim take ownership of the server I'm on
|
||||
// /felis migrate open a migration of my servers to another account
|
||||
// /felis web where the web consoles live
|
||||
// /felis web op approve <code> vouch for a pending op.console staff login (§B)
|
||||
// /felis web op approve <code> show whose op.console staff login a code is (§B)
|
||||
// /felis web op approve <code> <account> vouch for it, naming the account
|
||||
//
|
||||
// Two guards run before any subcommand that acts or reveals operational state:
|
||||
//
|
||||
@@ -357,9 +360,16 @@ public final class FelisVelocityPlugin {
|
||||
.then(BrigadierCommand.literalArgumentBuilder("approve")
|
||||
.then(BrigadierCommand.requiredArgumentBuilder("code", StringArgumentType.word())
|
||||
.executes(ctx -> {
|
||||
doOpApprove(ctx.getSource(), StringArgumentType.getString(ctx, "code"));
|
||||
doOpApprove(ctx.getSource(), StringArgumentType.getString(ctx, "code"), null);
|
||||
return Command.SINGLE_SUCCESS;
|
||||
})))))
|
||||
})
|
||||
.then(BrigadierCommand.requiredArgumentBuilder("account", StringArgumentType.word())
|
||||
.executes(ctx -> {
|
||||
doOpApprove(ctx.getSource(),
|
||||
StringArgumentType.getString(ctx, "code"),
|
||||
StringArgumentType.getString(ctx, "account"));
|
||||
return Command.SINGLE_SUCCESS;
|
||||
}))))))
|
||||
.build();
|
||||
CommandMeta meta = commands.metaBuilder("felis").plugin(this).build();
|
||||
commands.register(meta, new BrigadierCommand(node));
|
||||
@@ -447,7 +457,7 @@ public final class FelisVelocityPlugin {
|
||||
helpLine(source, "/felis web",
|
||||
zh ? "网页控制台地址" : "where the web consoles live");
|
||||
helpLine(source, "/felis web op approve <code>",
|
||||
zh ? "批准待处理的管理员登录" : "approve a pending operator sign-in");
|
||||
zh ? "查看并批准待处理的管理员登录" : "review and approve a pending operator sign-in");
|
||||
}
|
||||
|
||||
private void sendServerList(CommandSource source) {
|
||||
@@ -641,8 +651,8 @@ public final class FelisVelocityPlugin {
|
||||
source.sendMessage(field(zh ? "管理员" : "operators", "https://" + adminHost));
|
||||
}
|
||||
source.sendMessage(Component.text(
|
||||
zh ? " 管理员:/felis web op approve <code> 用于为待处理登录作担保"
|
||||
: " operators: /felis web op approve <code> vouches for a pending sign-in",
|
||||
zh ? " 管理员:/felis web op approve <code> 查看并批准待处理的登录"
|
||||
: " operators: /felis web op approve <code> reviews a pending sign-in",
|
||||
NamedTextColor.GRAY));
|
||||
}
|
||||
|
||||
@@ -661,12 +671,18 @@ public final class FelisVelocityPlugin {
|
||||
NamedTextColor.GRAY));
|
||||
source.sendMessage(Component.text(" /felis web op approve <code>", NamedTextColor.WHITE));
|
||||
source.sendMessage(Component.text(
|
||||
zh ? "即可为其担保——你必须是已绑定并在线的管理员。"
|
||||
: "to vouch for it — you must be an online, linked administrator.",
|
||||
zh ? "查看这是谁的登录,确认后输入其账户名即可担保——你必须是已绑定并在线的管理员。"
|
||||
: "to see whose sign-in it is, then confirm with their account name to vouch for it"
|
||||
+ " — you must be an online, linked administrator.",
|
||||
NamedTextColor.GRAY));
|
||||
}
|
||||
|
||||
private void doOpApprove(CommandSource source, String codeArg) {
|
||||
// doOpApprove is both halves of the in-game vouch. Without an account name it only
|
||||
// shows whose sign-in the code belongs to (OpApprovalCard); with one it approves,
|
||||
// and felis-api refuses unless the name is that request's account. The admin
|
||||
// therefore always reads the account before vouching, and a code someone else
|
||||
// relayed cannot be approved blind.
|
||||
private void doOpApprove(CommandSource source, String codeArg, String accountArg) {
|
||||
Player player = requirePlayer(source);
|
||||
if (player == null || !ensureOutOfLimbo(player)) {
|
||||
return;
|
||||
@@ -688,17 +704,35 @@ public final class FelisVelocityPlugin {
|
||||
}
|
||||
UUID approver = player.getUniqueId();
|
||||
String who = player.getUsername();
|
||||
if (accountArg == null) {
|
||||
async(() -> {
|
||||
try {
|
||||
OpLoginView req = apiClient.opLoginShow(code, approver);
|
||||
long age = req.createdAt() == null ? -1
|
||||
: Math.max(0, Duration.between(req.createdAt(), Instant.now()).getSeconds());
|
||||
for (Component line : OpApprovalCard.lines(req, code, zh, age)) {
|
||||
player.sendMessage(line);
|
||||
}
|
||||
} catch (LinkException e) {
|
||||
player.sendMessage(Component.text(opApproveError(e, code, zh), NamedTextColor.RED));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
String account = accountArg.trim();
|
||||
player.sendMessage(Component.text(
|
||||
zh ? "正在批准管理员登录……" : "Approving operator sign-in…", NamedTextColor.GRAY));
|
||||
async(() -> {
|
||||
try {
|
||||
apiClient.opLoginApprove(code, approver);
|
||||
OpLoginView done = apiClient.opLoginApprove(code, approver, account);
|
||||
player.sendMessage(Component.text(
|
||||
zh ? "已批准——对方现在可以完成登录了。"
|
||||
: "Approved — the operator can finish signing in now.", NamedTextColor.GREEN));
|
||||
logger.info("Felis: op-login {} approved in-game by {} ({})", code, who, approver);
|
||||
zh ? "已批准 " + done.username() + "(" + done.email() + ")的登录——对方现在可以完成登录了。"
|
||||
: "Approved " + done.username() + " (" + done.email()
|
||||
+ ") — they can finish signing in now.", NamedTextColor.GREEN));
|
||||
logger.info("Felis: op-login {} for {} approved in-game by {} ({})",
|
||||
code, done.username(), who, approver);
|
||||
} catch (LinkException e) {
|
||||
player.sendMessage(Component.text(opApproveError(e, zh), NamedTextColor.RED));
|
||||
player.sendMessage(Component.text(opApproveError(e, code, zh), NamedTextColor.RED));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1061,10 +1095,11 @@ public final class FelisVelocityPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
// opApproveError maps the internal approve refusals to player-safe text. A 403 is
|
||||
// the API's own admin re-check (defence in depth over the in-game gate); a 404
|
||||
// means no live pending request carries that code.
|
||||
private static String opApproveError(LinkException e, boolean zh) {
|
||||
// opApproveError maps the internal show/approve refusals to player-safe text. A 403
|
||||
// is the API's own admin re-check (defence in depth over the in-game gate); a 404
|
||||
// means no live pending request carries that code; a 409 means the typed account
|
||||
// is not the one the request is for.
|
||||
private static String opApproveError(LinkException e, String code, boolean zh) {
|
||||
switch (e.statusCode()) {
|
||||
case 403:
|
||||
return zh ? "只有已绑定的管理员才能批准管理员登录。"
|
||||
@@ -1072,6 +1107,10 @@ public final class FelisVelocityPlugin {
|
||||
case 404:
|
||||
return zh ? "没有携带该码的待处理管理员登录(可能已过期)。"
|
||||
: "No pending operator sign-in with that code (it may have expired).";
|
||||
case 409:
|
||||
return zh ? "该登录属于另一个账户,未批准。运行 /felis web op approve " + code + " 查看是谁的登录。"
|
||||
: "That sign-in is for a different account, so it was not approved. Run /felis web op approve "
|
||||
+ code + " to see whose it is.";
|
||||
case 0:
|
||||
return zh ? "Felis 暂时不可用——请稍后再试。"
|
||||
: "Felis is temporarily unavailable — please try again.";
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package best.lolicon.felis.velocity;
|
||||
|
||||
import best.lolicon.felis.link.OpLoginView;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.event.ClickEvent;
|
||||
import net.kyori.adventure.text.event.HoverEvent;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* OpApprovalCard builds what an admin sees after {@code /felis web op approve <code>}:
|
||||
* whose op.console sign-in the code belongs to (account, address, where and when it
|
||||
* was started), a warning to approve only a sign-in they know about, and how to
|
||||
* confirm. Confirming means typing the account name, because a code relayed by
|
||||
* someone else ("please approve abc123") is exactly the case this card exists to
|
||||
* catch; the button fills the command up to the name and leaves the name to the
|
||||
* admin.
|
||||
*
|
||||
* <p>A pure function of (request, code, language, age), like {@link InviteCard}, so
|
||||
* {@code OpApprovalCardTest} can check it without a proxy.
|
||||
*/
|
||||
final class OpApprovalCard {
|
||||
|
||||
static final String APPROVE_COMMAND = "/felis web op approve";
|
||||
|
||||
/** A user agent longer than this is cut, so one line of chat stays one line. */
|
||||
static final int USER_AGENT_MAX = 80;
|
||||
|
||||
private OpApprovalCard() {
|
||||
}
|
||||
|
||||
/**
|
||||
* lines renders the card in the approver's language. ageSeconds is how long ago the
|
||||
* sign-in was started, or a negative value when the API did not say.
|
||||
*/
|
||||
static List<Component> lines(OpLoginView req, String code, boolean zh, long ageSeconds) {
|
||||
List<Component> out = new ArrayList<>();
|
||||
out.add(Component.text(zh ? "待你批准的管理员登录" : "Operator sign-in waiting for your approval",
|
||||
NamedTextColor.AQUA));
|
||||
out.add(field(zh ? "账户" : "account", req.username() + " (" + req.email() + ")"));
|
||||
String from = req.clientIp().isEmpty() ? (zh ? "未知" : "unknown") : req.clientIp();
|
||||
if (!req.userAgent().isEmpty()) {
|
||||
from += " · " + shorten(req.userAgent());
|
||||
}
|
||||
out.add(field(zh ? "来源" : "from", from));
|
||||
if (ageSeconds >= 0) {
|
||||
out.add(field(zh ? "发起" : "started", age(ageSeconds, zh)));
|
||||
}
|
||||
out.add(Component.text(
|
||||
zh ? " 只在你确认此人正在登录时批准。有人私下发给你批准码时尤其要核对账户。"
|
||||
: " Approve only if you know this person is signing in right now — "
|
||||
+ "above all when someone else sent you the code.",
|
||||
NamedTextColor.YELLOW));
|
||||
|
||||
String prefill = APPROVE_COMMAND + " " + code + " ";
|
||||
Component button = Component.text(zh ? "[ 批准… ]" : "[ Approve… ]", NamedTextColor.GREEN)
|
||||
.clickEvent(ClickEvent.suggestCommand(prefill))
|
||||
.hoverEvent(HoverEvent.showText(Component.text(
|
||||
zh ? "点击后在末尾输入上面的账户名,再按回车"
|
||||
: "Click, then type the account name shown above and press Enter")));
|
||||
out.add(Component.text(" ").append(button).append(Component.text(
|
||||
zh ? " 或输入 " + APPROVE_COMMAND + " " + code + " <账户名>"
|
||||
: " or type " + APPROVE_COMMAND + " " + code + " <account name>",
|
||||
NamedTextColor.GRAY)));
|
||||
return out;
|
||||
}
|
||||
|
||||
static String shorten(String userAgent) {
|
||||
if (userAgent.length() <= USER_AGENT_MAX) {
|
||||
return userAgent;
|
||||
}
|
||||
return userAgent.substring(0, USER_AGENT_MAX - 1) + "…";
|
||||
}
|
||||
|
||||
static String age(long seconds, boolean zh) {
|
||||
if (seconds < 60) {
|
||||
return zh ? "刚刚" : "just now";
|
||||
}
|
||||
long minutes = seconds / 60;
|
||||
return zh ? minutes + " 分钟前" : minutes + " min ago";
|
||||
}
|
||||
|
||||
private static Component field(String key, String value) {
|
||||
return Component.text(" " + key + ": ", NamedTextColor.GRAY)
|
||||
.append(Component.text(value, NamedTextColor.WHITE));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package best.lolicon.felis.velocity;
|
||||
|
||||
import best.lolicon.felis.link.OpLoginView;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.TextComponent;
|
||||
import net.kyori.adventure.text.event.ClickEvent;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.format.TextColor;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* OpApprovalCardTest checks what an admin reads before vouching for an op.console
|
||||
* sign-in: the account, its address, where and when the sign-in started, and a
|
||||
* confirm button that leaves the account name for the admin to type. The name is the
|
||||
* point — a button that filled it in would let a relayed code be approved without
|
||||
* reading the card.
|
||||
*
|
||||
* <p>Hermetic and framework-free like {@link InviteCardTest}; it needs the shared link
|
||||
* sources (for {@link OpLoginView}) and the Kyori jars. Run: {@code javac -cp
|
||||
* <adventure-api.jar>:<adventure-key.jar>:<examination-api.jar> -d <out>
|
||||
* shared/src/main/java/best/lolicon/felis/link/*.java
|
||||
* velocity/src/main/java/best/lolicon/felis/velocity/OpApprovalCard.java
|
||||
* velocity/test/best/lolicon/felis/velocity/OpApprovalCardTest.java && java -cp
|
||||
* <out>:<adventure-api.jar>:<adventure-key.jar>:<examination-api.jar>
|
||||
* best.lolicon.felis.velocity.OpApprovalCardTest}.
|
||||
*/
|
||||
public final class OpApprovalCardTest {
|
||||
|
||||
private static int checks;
|
||||
|
||||
private static final OpLoginView REQ = new OpLoginView("abc123", "alice", "[email protected]",
|
||||
"203.0.113.9", "Mozilla/5.0 Firefox/140.0", Instant.parse("2023-11-14T22:13:20Z"));
|
||||
|
||||
public static void main(String[] args) {
|
||||
cardNamesTheAccountAndOrigin();
|
||||
englishCardReadsTheSame();
|
||||
buttonLeavesTheNameToTheAdmin();
|
||||
missingOriginStillReads();
|
||||
longUserAgentIsCut();
|
||||
ageTurnsIntoMinutesAtSixty();
|
||||
System.out.println("OpApprovalCardTest OK (" + checks + " checks)");
|
||||
}
|
||||
|
||||
private static void cardNamesTheAccountAndOrigin() {
|
||||
List<Component> card = OpApprovalCard.lines(REQ, "abc123", true, 150);
|
||||
assertEq("zh card", List.of(
|
||||
"待你批准的管理员登录",
|
||||
" 账户: alice ([email protected])",
|
||||
" 来源: 203.0.113.9 · Mozilla/5.0 Firefox/140.0",
|
||||
" 发起: 2 分钟前",
|
||||
" 只在你确认此人正在登录时批准。有人私下发给你批准码时尤其要核对账户。",
|
||||
" [ 批准… ] 或输入 /felis web op approve abc123 <账户名>"), text(card));
|
||||
assertEq("headline colour", NamedTextColor.AQUA, card.get(0).color());
|
||||
assertEq("warning colour", NamedTextColor.YELLOW, card.get(4).color());
|
||||
}
|
||||
|
||||
private static void englishCardReadsTheSame() {
|
||||
assertEq("en card", List.of(
|
||||
"Operator sign-in waiting for your approval",
|
||||
" account: alice ([email protected])",
|
||||
" from: 203.0.113.9 · Mozilla/5.0 Firefox/140.0",
|
||||
" started: just now",
|
||||
" Approve only if you know this person is signing in right now — above all when someone else sent you the code.",
|
||||
" [ Approve… ] or type /felis web op approve abc123 <account name>"),
|
||||
text(OpApprovalCard.lines(REQ, "abc123", false, 59)));
|
||||
}
|
||||
|
||||
// The button only fills the command up to the name: clicking it must not approve,
|
||||
// and must not type the name for the admin.
|
||||
private static void buttonLeavesTheNameToTheAdmin() {
|
||||
List<Component> card = OpApprovalCard.lines(REQ, "abc123", false, 0);
|
||||
Component button = card.get(card.size() - 1).children().get(0);
|
||||
assertEq("button colour", NamedTextColor.GREEN, button.color());
|
||||
// Action and text are compared as plain values: ClickEvent#toString needs
|
||||
// examination-string, which is not on this test's classpath.
|
||||
ClickEvent click = button.clickEvent();
|
||||
assertEq("button only fills the chat box", ClickEvent.Action.SUGGEST_COMMAND, click.action());
|
||||
assertEq("button fills the command up to the name", "/felis web op approve abc123 ",
|
||||
((ClickEvent.Payload.Text) click.payload()).value());
|
||||
assertEq("button hover", "Click, then type the account name shown above and press Enter",
|
||||
flat(button.hoverEvent().value()));
|
||||
}
|
||||
|
||||
private static void missingOriginStillReads() {
|
||||
OpLoginView bare = new OpLoginView("abc123", "alice", "[email protected]", "", "", null);
|
||||
assertEq("bare card", List.of(
|
||||
"Operator sign-in waiting for your approval",
|
||||
" account: alice ([email protected])",
|
||||
" from: unknown",
|
||||
" Approve only if you know this person is signing in right now — above all when someone else sent you the code.",
|
||||
" [ Approve… ] or type /felis web op approve abc123 <account name>"),
|
||||
text(OpApprovalCard.lines(bare, "abc123", false, -1)));
|
||||
}
|
||||
|
||||
private static void longUserAgentIsCut() {
|
||||
String ua = "x".repeat(200);
|
||||
OpLoginView req = new OpLoginView("abc123", "alice", "[email protected]", "203.0.113.9", ua, null);
|
||||
assertEq("from line", " from: 203.0.113.9 · " + "x".repeat(79) + "…",
|
||||
text(OpApprovalCard.lines(req, "abc123", false, -1)).get(2));
|
||||
assertEq("short agent kept", "Firefox/140.0", OpApprovalCard.shorten("Firefox/140.0"));
|
||||
assertEq("80 chars kept", "y".repeat(80), OpApprovalCard.shorten("y".repeat(80)));
|
||||
}
|
||||
|
||||
private static void ageTurnsIntoMinutesAtSixty() {
|
||||
assertEq("59s", "just now", OpApprovalCard.age(59, false));
|
||||
assertEq("60s", "1 min ago", OpApprovalCard.age(60, false));
|
||||
assertEq("599s", "9 分钟前", OpApprovalCard.age(599, true));
|
||||
}
|
||||
|
||||
// ---- harness ----
|
||||
|
||||
private static List<String> text(List<Component> card) {
|
||||
List<String> out = new ArrayList<>();
|
||||
for (Component c : card) {
|
||||
out.add(flat(c));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** flat joins a component's text with its children's, depth first. */
|
||||
private static String flat(Object value) {
|
||||
if (!(value instanceof Component)) {
|
||||
return String.valueOf(value);
|
||||
}
|
||||
Component c = (Component) value;
|
||||
StringBuilder sb = new StringBuilder(c instanceof TextComponent ? ((TextComponent) c).content() : "");
|
||||
for (Component child : c.children()) {
|
||||
sb.append(flat(child));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static void assertEq(String what, Object want, Object got) {
|
||||
if (want instanceof TextColor && got instanceof TextColor) {
|
||||
if (((TextColor) want).value() != ((TextColor) got).value()) {
|
||||
throw new AssertionError(what + " = " + got + ", want " + want);
|
||||
}
|
||||
checks++;
|
||||
return;
|
||||
}
|
||||
if (want == null ? got != null : !want.equals(got)) {
|
||||
throw new AssertionError(what + " = " + got + ", want " + want);
|
||||
}
|
||||
checks++;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user