mirror of
https://github.com/Grasscutters/Grasscutter.git
synced 2025-12-17 17:34:39 +01:00
Implement proper handbook authentication (pt. 1)
This commit is contained in:
@@ -1,22 +1,86 @@
|
||||
import type { CommandResponse } from "@backend/types";
|
||||
import emitter from "@backend/events";
|
||||
|
||||
let targetPlayer = 0; // The UID of the target player.
|
||||
let playerToken: string | null = null; // The session token for the player.
|
||||
export let targetPlayer = 0; // The UID of the target player.
|
||||
|
||||
// The server's address and port.
|
||||
export let address: string = "127.0.0.1", port: string = "443";
|
||||
export let encrypted: boolean = true;
|
||||
|
||||
export let lockedPlayer = false; // Whether the UID field is locked.
|
||||
export let connected = false; // Whether the server is connected.
|
||||
|
||||
/**
|
||||
* Loads the server details from local storage.
|
||||
*/
|
||||
export function setup(): void {
|
||||
// Load the server details from local storage.
|
||||
const storedAddress = localStorage.getItem("address");
|
||||
const storedPort = localStorage.getItem("port");
|
||||
|
||||
// Set the server details.
|
||||
if (storedAddress) address = storedAddress;
|
||||
if (storedPort) port = storedPort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the formed URL.
|
||||
* This assumes that the server upgrades to HTTPS.
|
||||
*/
|
||||
export function url(): string {
|
||||
// noinspection HttpUrlsUsage
|
||||
return `http${window.isSecureContext || encrypted ? "s" : ""}://${address}:${port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the target player.
|
||||
*
|
||||
* @param player The UID of the target player.
|
||||
* @param token The session token for the player.
|
||||
*/
|
||||
export function setTargetPlayer(player: number): void {
|
||||
export function setTargetPlayer(player: number, token: string | null = null): void {
|
||||
playerToken = token;
|
||||
targetPlayer = player;
|
||||
|
||||
// Determine connected status.
|
||||
connected = !isNaN(player) && player > 0;
|
||||
// Determine locked status.
|
||||
lockedPlayer = connected && token != null;
|
||||
|
||||
// Emit the connected event.
|
||||
emitter.emit("connected", connected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the server details.
|
||||
*
|
||||
* @param newAddress The server's address.
|
||||
* @param newPort The server's port.
|
||||
*/
|
||||
export async function setServerDetails(newAddress: string | null, newPort: string | null): Promise<void> {
|
||||
// Apply the new details.
|
||||
if (newAddress != null) {
|
||||
address = newAddress;
|
||||
localStorage.setItem("address", newAddress);
|
||||
}
|
||||
if (newPort != null) {
|
||||
port = newPort;
|
||||
localStorage.setItem("port", newPort);
|
||||
}
|
||||
|
||||
// Check if the server is encrypted.
|
||||
return new Promise((resolve) => {
|
||||
encrypted = true;
|
||||
fetch(`${url()}`)
|
||||
.catch(() => {
|
||||
encrypted = false;
|
||||
resolve();
|
||||
})
|
||||
.then(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a number.
|
||||
*
|
||||
@@ -44,9 +108,10 @@ export async function grantAvatar(
|
||||
if (invalid(avatar) || invalid(level) || invalid(constellations) || invalid(talents))
|
||||
return { status: -1, message: "Invalid arguments." };
|
||||
|
||||
return await fetch(`https://localhost:443/handbook/avatar`, {
|
||||
return await fetch(`${url()}/handbook/avatar`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
playerToken,
|
||||
player: targetPlayer.toString(),
|
||||
avatar: avatar.toString(),
|
||||
level,
|
||||
@@ -68,9 +133,10 @@ export async function giveItem(item: number, amount = 1): Promise<CommandRespons
|
||||
// Validate the number.
|
||||
if (isNaN(amount) || amount < 1) return { status: -1, message: "Invalid amount." };
|
||||
|
||||
return await fetch(`https://localhost:443/handbook/item`, {
|
||||
return await fetch(`${url()}/handbook/item`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
playerToken,
|
||||
player: targetPlayer.toString(),
|
||||
item: item.toString(),
|
||||
amount
|
||||
@@ -87,9 +153,10 @@ export async function teleportTo(scene: number): Promise<CommandResponse> {
|
||||
// Validate the number.
|
||||
if (isNaN(scene) || scene < 1) return { status: -1, message: "Invalid scene." };
|
||||
|
||||
return await fetch(`https://localhost:443/handbook/teleport`, {
|
||||
return await fetch(`${url()}/handbook/teleport`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
playerToken,
|
||||
player: targetPlayer.toString(),
|
||||
scene: scene.toString()
|
||||
})
|
||||
@@ -108,9 +175,10 @@ export async function spawnEntity(entity: number, amount = 1, level = 1): Promis
|
||||
if (isNaN(entity) || isNaN(amount) || isNaN(level) || amount < 1 || level < 1 || level > 200)
|
||||
return { status: -1, message: "Invalid arguments." };
|
||||
|
||||
return await fetch(`https://localhost:443/handbook/spawn`, {
|
||||
return await fetch(`${url()}/handbook/spawn`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
playerToken,
|
||||
player: targetPlayer.toString(),
|
||||
entity: entity.toString(),
|
||||
amount,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type Page = "Home" | "Commands" | "Avatars" | "Items" | "Entities" | "Scenes";
|
||||
export type Overlays = "None" | "ServerSettings";
|
||||
export type Days = "Sunday" | "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday";
|
||||
|
||||
export type Command = {
|
||||
|
||||
@@ -46,6 +46,12 @@ body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
}
|
||||
|
||||
.EntitiesPage_Input {
|
||||
background-color: transparent;
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
color: var(--text-primary-color);
|
||||
|
||||
12
src/handbook/src/css/views/Overlay.scss
Normal file
12
src/handbook/src/css/views/Overlay.scss
Normal file
@@ -0,0 +1,12 @@
|
||||
.Overlay {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
|
||||
background-color: rgb(0, 0, 0, 0.35);
|
||||
}
|
||||
@@ -72,3 +72,9 @@
|
||||
color: var(--text-secondary-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.SideBar_Input:disabled {
|
||||
cursor: not-allowed;
|
||||
border-radius: 10px ;
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
117
src/handbook/src/css/widgets/ServerSettings.scss
Normal file
117
src/handbook/src/css/widgets/ServerSettings.scss
Normal file
@@ -0,0 +1,117 @@
|
||||
.ServerSettings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-radius: 10px;
|
||||
|
||||
background-color: var(--accent-color);
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 620px;
|
||||
max-height: 400px;
|
||||
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ServerSettings_Content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
|
||||
width: 100%;
|
||||
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.ServerSettings_Top {
|
||||
height: 80%;
|
||||
}
|
||||
|
||||
.ServerSettings_Frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.ServerSettings_Title {
|
||||
font-weight: bold;
|
||||
font-size: 34px;
|
||||
|
||||
text-align: center;
|
||||
margin-bottom: 15px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ServerSettings_Details {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
|
||||
border-radius: 10px;
|
||||
background-color: var(--secondary-color);
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 590px;
|
||||
max-height: 50px;
|
||||
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
|
||||
p {
|
||||
font-size: 20px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
input {
|
||||
border: none;
|
||||
background: none;
|
||||
|
||||
font-size: 18px;
|
||||
color: var(--text-primary-color);
|
||||
|
||||
&:focus, &:active {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ServerSettings_Authenticate {
|
||||
font-size: 20px;
|
||||
border-radius: 10px;
|
||||
background-color: var(--secondary-color);
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 210px;
|
||||
max-height: 46px;
|
||||
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ServerSettings_Save {
|
||||
font-size: 20px;
|
||||
border-radius: 10px;
|
||||
background-color: var(--secondary-color);
|
||||
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
max-width: 120px;
|
||||
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
}
|
||||
@@ -3,12 +3,14 @@ import { createRoot } from "react-dom/client";
|
||||
|
||||
import * as data from "@backend/data";
|
||||
import * as events from "@backend/events";
|
||||
import * as server from "@backend/server";
|
||||
|
||||
import App from "@ui/App";
|
||||
|
||||
// Call initial setup functions.
|
||||
data.setup();
|
||||
events.setup();
|
||||
server.setup();
|
||||
|
||||
// Render the application.
|
||||
createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
|
||||
import SideBar from "@views/SideBar";
|
||||
import Content from "@views/Content";
|
||||
import Overlay from "@views/Overlay";
|
||||
import PlainText from "@views/PlainText";
|
||||
|
||||
import type { Page } from "@backend/types";
|
||||
@@ -25,7 +26,6 @@ class App extends React.Component<{}, IState> {
|
||||
// Check if the window's href is a page.
|
||||
let targetPage = null;
|
||||
const page = window.location.href.split("/").pop();
|
||||
console.log(page);
|
||||
|
||||
if (page != undefined && page != "") {
|
||||
// Convert the page to a Page type.
|
||||
@@ -44,7 +44,16 @@ class App extends React.Component<{}, IState> {
|
||||
return (
|
||||
<div className={"App"}>
|
||||
<SideBar />
|
||||
{this.state.plain ? <PlainText /> : <Content initial={this.state.initial} />}
|
||||
|
||||
{
|
||||
this.state.plain ?
|
||||
<PlainText /> :
|
||||
<Content
|
||||
initial={this.state.initial}
|
||||
/>
|
||||
}
|
||||
|
||||
<Overlay />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
61
src/handbook/src/ui/views/Overlay.tsx
Normal file
61
src/handbook/src/ui/views/Overlay.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from "react";
|
||||
|
||||
import ServerSettings from "@widgets/ServerSettings";
|
||||
|
||||
import type { Overlays } from "@backend/types";
|
||||
|
||||
import "@css/views/Overlay.scss";
|
||||
import events from "@backend/events";
|
||||
|
||||
interface IState {
|
||||
page: Overlays;
|
||||
}
|
||||
|
||||
class Overlay extends React.Component<{}, IState> {
|
||||
constructor(props: {}) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
page: "None"
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the page to display.
|
||||
*
|
||||
* @param page The page to display.
|
||||
*/
|
||||
private setPage(page: Overlays): void {
|
||||
this.setState({ page });
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the page to display.
|
||||
*/
|
||||
private getPage(): React.ReactNode {
|
||||
switch (this.state.page) {
|
||||
default:
|
||||
return undefined;
|
||||
case "ServerSettings":
|
||||
return <ServerSettings />;
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
events.on("overlay", this.setPage.bind(this));
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
events.off("overlay", this.setPage.bind(this));
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.state.page != "None" ? (
|
||||
<div className={"Overlay"}>
|
||||
{this.getPage()}
|
||||
</div>
|
||||
) : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export default Overlay;
|
||||
@@ -10,13 +10,14 @@ import Icon_Map from "@assets/Icon_Map.webp";
|
||||
import Icon_Quests from "@assets/Icon_Quests.webp";
|
||||
import Icon_Achievements from "@assets/Icon_Achievements.webp";
|
||||
|
||||
import { navigate } from "@backend/events";
|
||||
import { setTargetPlayer } from "@backend/server";
|
||||
import events, { navigate } from "@backend/events";
|
||||
import { targetPlayer, lockedPlayer, setTargetPlayer } from "@backend/server";
|
||||
|
||||
import "@css/views/SideBar.scss";
|
||||
|
||||
interface IState {
|
||||
uid: string | null;
|
||||
uidLocked: boolean;
|
||||
}
|
||||
|
||||
class SideBar extends React.Component<{}, IState> {
|
||||
@@ -24,10 +25,22 @@ class SideBar extends React.Component<{}, IState> {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
uid: null
|
||||
uid: targetPlayer > 0 ? targetPlayer.toString() : null,
|
||||
uidLocked: lockedPlayer
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoked when the player's UID changes.
|
||||
* @private
|
||||
*/
|
||||
private updateUid(): void {
|
||||
this.setState({
|
||||
uid: targetPlayer > 0 ? targetPlayer.toString() : null,
|
||||
uidLocked: lockedPlayer
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoked when the UID input changes.
|
||||
*
|
||||
@@ -39,10 +52,32 @@ class SideBar extends React.Component<{}, IState> {
|
||||
const uid = input == "" ? null : input;
|
||||
if (uid && uid.length > 10) return;
|
||||
|
||||
this.setState({ uid });
|
||||
setTargetPlayer(parseInt(uid ?? "0"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoked when the UID input is right-clicked.
|
||||
*
|
||||
* @param event The event.
|
||||
* @private
|
||||
*/
|
||||
private onRightClick(event: React.MouseEvent<HTMLInputElement, MouseEvent>): void {
|
||||
// Remove focus from the input.
|
||||
event.currentTarget.blur();
|
||||
event.preventDefault();
|
||||
|
||||
// Open the server settings overlay.
|
||||
events.emit("overlay", "ServerSettings");
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
events.on("connected", this.updateUid.bind(this));
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
events.off("connected", this.updateUid.bind(this));
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={"SideBar"}>
|
||||
@@ -74,7 +109,9 @@ class SideBar extends React.Component<{}, IState> {
|
||||
className={"SideBar_Input"}
|
||||
placeholder={"Enter UID..."}
|
||||
value={this.state.uid ?? undefined}
|
||||
disabled={this.state.uidLocked}
|
||||
onChange={this.onChange.bind(this)}
|
||||
onContextMenu={this.onRightClick.bind(this)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
176
src/handbook/src/ui/widgets/ServerSettings.tsx
Normal file
176
src/handbook/src/ui/widgets/ServerSettings.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import React from "react";
|
||||
|
||||
import emitter from "@backend/events";
|
||||
import {
|
||||
targetPlayer, address, port,
|
||||
setServerDetails, url, setTargetPlayer
|
||||
} from "@backend/server";
|
||||
|
||||
import "@css/widgets/ServerSettings.scss";
|
||||
|
||||
interface IState {
|
||||
webview: boolean;
|
||||
|
||||
address: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
class ServerSettings extends React.Component<{}, IState> {
|
||||
constructor(props: {}) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
webview: false,
|
||||
address: address,
|
||||
port: Number(port)
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener("keyup", this.escapeListener.bind(this));
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("keyup", this.escapeListener.bind(this));
|
||||
window.removeEventListener("message", this.handleAuthentication.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoked when the escape key is pressed.
|
||||
*
|
||||
* @param e The keyboard event.
|
||||
* @private
|
||||
*/
|
||||
private escapeListener(e: KeyboardEvent): void {
|
||||
if (e.key === "Escape") {
|
||||
// Hide the overlay.
|
||||
emitter.emit("overlay", "None");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoked when the component tries to authenticate.
|
||||
* @private
|
||||
*/
|
||||
private authenticate(): void {
|
||||
setServerDetails(null, null).then(() => {
|
||||
this.setState({ webview: true });
|
||||
});
|
||||
|
||||
// Add the event listener for authentication.
|
||||
window.addEventListener("message", this.handleAuthentication.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Finishes the authentication process.
|
||||
*
|
||||
* @param e The message event.
|
||||
* @private
|
||||
*/
|
||||
private handleAuthentication(e: MessageEvent): void {
|
||||
const data = e.data; // The data sent from the server.
|
||||
if (data == null) return; // If the data is null, return.
|
||||
|
||||
// Check if the data is an object.
|
||||
if (typeof data != "object") return;
|
||||
// Get the data type.
|
||||
const type = data["type"] ?? null;
|
||||
if (type != "handbook-auth") return;
|
||||
|
||||
// Get the data.
|
||||
const uid = data["uid"] ?? null;
|
||||
const token = data["token"] ?? null;
|
||||
|
||||
// Hide the overlay.
|
||||
emitter.emit("overlay", "None");
|
||||
// Set the token and user ID.
|
||||
setTargetPlayer(Number(uid), token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoked when the save button is clicked.
|
||||
* @private
|
||||
*/
|
||||
private save(): void {
|
||||
// Hide the overlay.
|
||||
emitter.emit("overlay", "None");
|
||||
|
||||
// Save the server settings.
|
||||
setServerDetails(
|
||||
this.state.address,
|
||||
this.state.port.toString()
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={"ServerSettings"}>
|
||||
{
|
||||
this.state.webview ? (
|
||||
<iframe
|
||||
className={"ServerSettings_Frame"}
|
||||
src={`${url()}/handbook/authenticate?uid=${targetPlayer}`}
|
||||
/>
|
||||
) : <>
|
||||
<div className={"ServerSettings_Content ServerSettings_Top"}>
|
||||
<h1 className={"ServerSettings_Title"}>Server Settings</h1>
|
||||
|
||||
<div className={"ServerSettings_Details"}>
|
||||
<div>
|
||||
<p>Address:</p>
|
||||
<input
|
||||
type={"text"}
|
||||
value={this.state.address}
|
||||
onChange={(e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const value = target.value;
|
||||
|
||||
this.setState({ address: value });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p>Port:</p>
|
||||
<input
|
||||
type={"text"}
|
||||
value={this.state.port == 0 ? "" : this.state.port}
|
||||
onChange={(e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const value = target.value;
|
||||
|
||||
if (isNaN(Number(value))
|
||||
|| value.length > 5) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ port: Number(value) });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={"ServerSettings_Authenticate"}
|
||||
onClick={this.authenticate.bind(this)}
|
||||
>
|
||||
Authenticate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={"ServerSettings_Content"}>
|
||||
<button
|
||||
className={"ServerSettings_Save"}
|
||||
onClick={this.save.bind(this)}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ServerSettings;
|
||||
Reference in New Issue
Block a user