2 car garage
This commit is contained in:
parent
2bf37eb5e8
commit
5b1db5d6de
10
data/config.json
Normal file
10
data/config.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"api": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 3000
|
||||||
|
},
|
||||||
|
"scanner": {
|
||||||
|
"updateCron": "*/1 * * * *",
|
||||||
|
"timeout": 2000
|
||||||
|
}
|
||||||
|
}
|
BIN
data/db.sqlite
Normal file
BIN
data/db.sqlite
Normal file
Binary file not shown.
62
data/servers.json
Normal file
62
data/servers.json
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "WildPrison",
|
||||||
|
"ip": "wildprison.net",
|
||||||
|
"type": "PC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Hypixel",
|
||||||
|
"ip": "mc.hypixel.net",
|
||||||
|
"type": "PC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CubeCraft",
|
||||||
|
"ip": "play.cubecraft.net",
|
||||||
|
"type": "PC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mineplex",
|
||||||
|
"ip": "mineplex.com",
|
||||||
|
"type": "PC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "2b2t",
|
||||||
|
"ip": "2b2t.org",
|
||||||
|
"type": "PC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AkumaMC",
|
||||||
|
"ip": "akumamc.net",
|
||||||
|
"type": "PC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Wynncraft",
|
||||||
|
"ip": "play.wynncraft.com",
|
||||||
|
"type": "PC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Minehut",
|
||||||
|
"ip": "minehut.com",
|
||||||
|
"type": "PC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Grand Theft Minecraft",
|
||||||
|
"ip": "gtm.network",
|
||||||
|
"type": "PC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "HiveMC",
|
||||||
|
"ip": "geo.hivebedrock.network",
|
||||||
|
"type": "PE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Purple Prison",
|
||||||
|
"ip": "MCSL.PURPLE.WTF",
|
||||||
|
"type": "PC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MinecraftOnline",
|
||||||
|
"ip": "minecraftonline.com",
|
||||||
|
"type": "PC"
|
||||||
|
}
|
||||||
|
]
|
4084
package-lock.json
generated
Normal file
4084
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nodemon --exec ts-node src/index.ts"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.8",
|
||||||
|
"@types/mcping-js": "^1.5.4",
|
||||||
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"better-sqlite3": "^9.2.2",
|
||||||
|
"dns": "^0.2.2",
|
||||||
|
"mcping-js": "^1.5.0",
|
||||||
|
"node-cron": "^3.0.3",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.10.6"
|
||||||
|
}
|
||||||
|
}
|
1297
pnpm-lock.yaml
generated
Normal file
1297
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
5
src/index.ts
Normal file
5
src/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import Scanner from "./scanner/scanner";
|
||||||
|
import ServerManager from "./server/serverManager";
|
||||||
|
|
||||||
|
export const serverManager = new ServerManager();
|
||||||
|
new Scanner();
|
158
src/scanner/scanner.ts
Normal file
158
src/scanner/scanner.ts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import Database from "better-sqlite3";
|
||||||
|
import cron from "node-cron";
|
||||||
|
import { serverManager } from "..";
|
||||||
|
|
||||||
|
import Config from "../../data/config.json";
|
||||||
|
import Server, { PingResponse } from "../server/server";
|
||||||
|
|
||||||
|
const DATA_DIR = "data";
|
||||||
|
|
||||||
|
const PINGS_TABLE = "pings";
|
||||||
|
const RECORD_TABLE = "record";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQL Queries
|
||||||
|
*/
|
||||||
|
const CREATE_TABLE = `
|
||||||
|
CREATE TABLE IF NOT EXISTS {} (
|
||||||
|
timestamp BIGINT NOT NULL,
|
||||||
|
ip TINYTEXT NOT NULL,
|
||||||
|
player_count MEDIUMINT NOT NULL
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
const CREATE_PINGS_TABLE = CREATE_TABLE.replace("{}", PINGS_TABLE);
|
||||||
|
const CREATE_RECORD_TABLE = CREATE_TABLE.replace("{}", RECORD_TABLE);
|
||||||
|
|
||||||
|
const CREATE_PINGS_INDEX = `CREATE INDEX IF NOT EXISTS ip_index ON pings (ip, player_count)`;
|
||||||
|
const CREATE_TIMESTAMP_INDEX = `CREATE INDEX IF NOT EXISTS timestamp_index on PINGS (timestamp)`;
|
||||||
|
|
||||||
|
const INSERT_PING = `
|
||||||
|
INSERT INTO ${PINGS_TABLE} (timestamp, ip, player_count)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
`;
|
||||||
|
const INSERT_RECORD = `
|
||||||
|
INSERT INTO ${RECORD_TABLE} (timestamp, ip, player_count)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
`;
|
||||||
|
const DELETE_OLD_RECORD = `
|
||||||
|
DELETE FROM ${RECORD_TABLE}
|
||||||
|
WHERE ip = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default class Scanner {
|
||||||
|
private db: Database.Database;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
console.log("Loading scanner database");
|
||||||
|
this.db = new Database(`${DATA_DIR}/db.sqlite`);
|
||||||
|
|
||||||
|
console.log("Ensuring tables exist");
|
||||||
|
this.db.exec(CREATE_PINGS_TABLE); // Ensure the pings table exists
|
||||||
|
this.db.exec(CREATE_RECORD_TABLE); // Ensure the record table exists
|
||||||
|
|
||||||
|
console.log("Ensuring indexes exist");
|
||||||
|
this.db.exec(CREATE_PINGS_INDEX); // Ensure the pings index exists
|
||||||
|
this.db.exec(CREATE_TIMESTAMP_INDEX); // Ensure the timestamp index exists
|
||||||
|
|
||||||
|
console.log("Starting server scan");
|
||||||
|
cron.schedule(Config.scanner.updateCron, () => {
|
||||||
|
this.scanServers();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a server scan to ping all servers.
|
||||||
|
*/
|
||||||
|
private async scanServers(): Promise<void> {
|
||||||
|
console.log(`Scanning servers ${serverManager.getServers().length}`);
|
||||||
|
|
||||||
|
// ping all servers in parallel
|
||||||
|
await Promise.all(
|
||||||
|
serverManager.getServers().map((server) => this.scanServer(server))
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("Finished scanning servers");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans a server and inserts the ping into the database.
|
||||||
|
*
|
||||||
|
* @param server the server to scan
|
||||||
|
* @returns a promise that resolves when the server has been scanned
|
||||||
|
*/
|
||||||
|
async scanServer(server: Server): Promise<void> {
|
||||||
|
//console.log(`Scanning server ${server.getIP()} - ${server.getType()}`);
|
||||||
|
let response;
|
||||||
|
let online = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await server.pingServer(server);
|
||||||
|
if (response == undefined) {
|
||||||
|
return; // Server is offline
|
||||||
|
}
|
||||||
|
online = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`Failed to ping ${server.getIP()}`, err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!online || !response) {
|
||||||
|
return; // Server is offline
|
||||||
|
}
|
||||||
|
|
||||||
|
const { timestamp, players } = response;
|
||||||
|
|
||||||
|
this.insertPing(timestamp, server.getIP(), players.online);
|
||||||
|
this.updateRecord(server, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the record for a server.
|
||||||
|
*
|
||||||
|
* @param server the server to update
|
||||||
|
* @param response the response to update with
|
||||||
|
*/
|
||||||
|
private updateRecord(server: Server, response: PingResponse): void {
|
||||||
|
const ip = server.getIP();
|
||||||
|
|
||||||
|
// select record from database for this server
|
||||||
|
const statement = this.db.prepare(
|
||||||
|
`SELECT * FROM ${RECORD_TABLE} WHERE ip = ?`
|
||||||
|
);
|
||||||
|
const record = statement.get(ip);
|
||||||
|
|
||||||
|
// delete old record
|
||||||
|
if (record) {
|
||||||
|
this.db.prepare(DELETE_OLD_RECORD).run(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.insertRecord(server, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts a ping into the database.
|
||||||
|
*
|
||||||
|
* @param timestamp the timestamp of the ping
|
||||||
|
* @param ip the IP address of the server
|
||||||
|
* @param playerCount the number of players online
|
||||||
|
*/
|
||||||
|
private insertPing(timestamp: number, ip: string, playerCount: number): void {
|
||||||
|
const statement = this.db.prepare(INSERT_PING);
|
||||||
|
statement.run(timestamp, ip, playerCount); // Insert the ping into the database
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts a record into the database.
|
||||||
|
*
|
||||||
|
* @param server the server to insert
|
||||||
|
* @param response the response to insert
|
||||||
|
*/
|
||||||
|
private insertRecord(server: Server, response: PingResponse): void {
|
||||||
|
const { timestamp, players } = response;
|
||||||
|
const ip = server.getIP();
|
||||||
|
const onlineCount = players.online;
|
||||||
|
|
||||||
|
const statement = this.db.prepare(INSERT_RECORD);
|
||||||
|
statement.run(timestamp, ip, onlineCount); // Insert the record into the database
|
||||||
|
}
|
||||||
|
}
|
181
src/server/server.ts
Normal file
181
src/server/server.ts
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
import { ResolvedServer, resolveDns } from "../utils/dnsResolver";
|
||||||
|
import JavaPing = require("mcping-js");
|
||||||
|
|
||||||
|
import Config from "../../data/config.json";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of server.
|
||||||
|
*
|
||||||
|
* PC: Java Edition - PE: Bedrock Edition
|
||||||
|
*/
|
||||||
|
export type ServerType = "PC" | "PE";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The response from a ping request to a server.
|
||||||
|
*/
|
||||||
|
export type PingResponse = {
|
||||||
|
timestamp: number;
|
||||||
|
ip: string;
|
||||||
|
version: string;
|
||||||
|
players: {
|
||||||
|
online: number;
|
||||||
|
max: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type ServerOptions = {
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
type: ServerType;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DnsInfo = {
|
||||||
|
hasResolved: boolean;
|
||||||
|
resolvedServer?: ResolvedServer;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class Server {
|
||||||
|
/**
|
||||||
|
* The name of the server.
|
||||||
|
*/
|
||||||
|
private name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The IP address of the server.
|
||||||
|
*/
|
||||||
|
private ip: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of server.
|
||||||
|
*/
|
||||||
|
private type: ServerType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The resolved server information from
|
||||||
|
* DNS records for a PC server.
|
||||||
|
*/
|
||||||
|
private dnsInfo: DnsInfo = {
|
||||||
|
hasResolved: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor({ name, ip, type }: ServerOptions) {
|
||||||
|
this.name = name;
|
||||||
|
this.ip = ip;
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pings a server and gets the response.
|
||||||
|
*
|
||||||
|
* @param server the server to ping
|
||||||
|
* @returns the ping response or undefined if the server is offline
|
||||||
|
*/
|
||||||
|
public pingServer(server: Server): Promise<PingResponse | undefined> {
|
||||||
|
switch (server.getType()) {
|
||||||
|
case "PC": {
|
||||||
|
return this.pingPCServer(server);
|
||||||
|
}
|
||||||
|
case "PE": {
|
||||||
|
return this.pingPEServer(server);
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
throw new Error(
|
||||||
|
`Unknown server type ${server.getType()} for ${server.getName()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pings a PC server and gets the response.
|
||||||
|
*
|
||||||
|
* @param server the server to ping
|
||||||
|
* @returns the ping response or undefined if the server is offline
|
||||||
|
*/
|
||||||
|
private async pingPCServer(
|
||||||
|
server: Server
|
||||||
|
): Promise<PingResponse | undefined> {
|
||||||
|
if (this.dnsInfo.resolvedServer == undefined && !this.dnsInfo.hasResolved) {
|
||||||
|
try {
|
||||||
|
const resolvedServer = await resolveDns(server.getIP());
|
||||||
|
|
||||||
|
this.dnsInfo = {
|
||||||
|
hasResolved: true,
|
||||||
|
resolvedServer: resolvedServer,
|
||||||
|
};
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hasResolved, resolvedServer } = this.dnsInfo;
|
||||||
|
|
||||||
|
let ip: string;
|
||||||
|
let port: number;
|
||||||
|
|
||||||
|
if (hasResolved && resolvedServer != undefined) {
|
||||||
|
ip = resolvedServer.ip;
|
||||||
|
port = resolvedServer.port;
|
||||||
|
} else {
|
||||||
|
ip = server.getIP();
|
||||||
|
port = 25565; // The default port
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverPing = new JavaPing.MinecraftServer(ip, port);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
serverPing.ping(Config.scanner.timeout, 700, (err, res) => {
|
||||||
|
if (err || res == undefined) {
|
||||||
|
return reject(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
ip: ip,
|
||||||
|
version: res.version.name,
|
||||||
|
players: {
|
||||||
|
online: res.players.online,
|
||||||
|
max: res.players.max,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pings a PE server and gets the response.
|
||||||
|
*
|
||||||
|
* @param server the server to ping
|
||||||
|
* @returns the ping response or undefined if the server is offline
|
||||||
|
*/
|
||||||
|
private async pingPEServer(
|
||||||
|
server: Server
|
||||||
|
): Promise<PingResponse | undefined> {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the name of the server.
|
||||||
|
*
|
||||||
|
* @returns the name
|
||||||
|
*/
|
||||||
|
public getName(): string {
|
||||||
|
return this.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the IP address of the server.
|
||||||
|
*
|
||||||
|
* @returns the IP address
|
||||||
|
*/
|
||||||
|
public getIP(): string {
|
||||||
|
return this.ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the type of server.
|
||||||
|
*
|
||||||
|
* @returns the type
|
||||||
|
*/
|
||||||
|
public getType(): ServerType {
|
||||||
|
return this.type;
|
||||||
|
}
|
||||||
|
}
|
28
src/server/serverManager.ts
Normal file
28
src/server/serverManager.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import Server, { ServerType } from "./server";
|
||||||
|
|
||||||
|
import Servers from "../../data/servers.json";
|
||||||
|
|
||||||
|
export default class ServerManager {
|
||||||
|
private servers: Server[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
for (const server of Servers) {
|
||||||
|
this.servers.push(
|
||||||
|
new Server({
|
||||||
|
ip: server.ip,
|
||||||
|
name: server.name,
|
||||||
|
type: server.type as ServerType,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the servers.
|
||||||
|
*
|
||||||
|
* @returns the servers
|
||||||
|
*/
|
||||||
|
public getServers(): Server[] {
|
||||||
|
return this.servers;
|
||||||
|
}
|
||||||
|
}
|
43
src/utils/dnsResolver.ts
Normal file
43
src/utils/dnsResolver.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import dns from "dns";
|
||||||
|
|
||||||
|
export type ResolvedServer = {
|
||||||
|
/**
|
||||||
|
* The IP address of the server.
|
||||||
|
*/
|
||||||
|
ip: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The port of the server.
|
||||||
|
*/
|
||||||
|
port: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a minecraft server domain to an
|
||||||
|
* IP address and port using the SRV record.
|
||||||
|
*
|
||||||
|
* @param domain the domain to resolve
|
||||||
|
* @returns the resolved minecraft server
|
||||||
|
*/
|
||||||
|
export async function resolveDns(domain: string): Promise<ResolvedServer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
dns.resolveSrv(`_minecraft._tcp.${domain}`, (err, records) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
const record = records[0];
|
||||||
|
if (record == undefined) {
|
||||||
|
return reject(undefined);
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
ip: record.name,
|
||||||
|
port: record.port,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
reject(undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
41
src/utils/fsUtils.ts
Normal file
41
src/utils/fsUtils.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a directory at the given path.
|
||||||
|
*
|
||||||
|
* @param path the path to the file
|
||||||
|
* @param recursive whether to create the directory tree if it doesn't exist (defaults to true)
|
||||||
|
* @returns a promise that resolves when the file is created
|
||||||
|
*/
|
||||||
|
export async function createDirectory(
|
||||||
|
path: string,
|
||||||
|
recursive?: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
if (recursive == undefined) {
|
||||||
|
recursive = true; // Set to true by default
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
fs.mkdir(path, { recursive: recursive }, (err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a file exists at the given path.
|
||||||
|
*
|
||||||
|
* @param path the path to the file
|
||||||
|
* @returns a promise that returns true if the file exists, false otherwise
|
||||||
|
*/
|
||||||
|
export async function exists(path: string): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
fs.exists(path, (exists) => {
|
||||||
|
resolve(exists);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"compilerOptions": {
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"target": "es2022",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"module": "NodeNext",
|
||||||
|
"noEmit": true,
|
||||||
|
"lib": ["es2022", "dom", "dom.iterable"]
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user