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

@@ -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++;
}
}