feat(api): op-login 游戏内审批先展示目标账号、邮箱与发起来源,须输入账户名确认,velocity 显示审批卡片
This commit is contained in:
21 files changed
+1088
-176
No files matched your search
+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