Initial commit
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
node_modules
|
||||||
|
public/bundle.*
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
*swp
|
||||||
|
*swo
|
||||||
108
api/admin.js
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import express from 'express'
|
||||||
|
import * as db from './db.js'
|
||||||
|
import { get_pagination, fetch_user_middleware } from './util.js'
|
||||||
|
|
||||||
|
const api = express.Router();
|
||||||
|
|
||||||
|
api.param("user", fetch_user_middleware);
|
||||||
|
|
||||||
|
api.get('/users', async (req, res, next) => {
|
||||||
|
const pagination = get_pagination(req);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accounts = await db.get_accounts_login_info(pagination, db.ACCOUNT.CREATED, db.SQL_ORDER.DESCENDING);
|
||||||
|
res.status(200).json({ users: accounts, page: pagination})
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
|
res.status(500).json({ message: 'error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.post('/search', async (req, res, next) => {
|
||||||
|
const pagination = get_pagination(req);
|
||||||
|
|
||||||
|
let search = req.body.search;
|
||||||
|
|
||||||
|
if (!search || search.length < 3) {
|
||||||
|
res.status(400).json({ message: 'Need a longer search term' });
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = await db.search(search, pagination);
|
||||||
|
res.status(200).json({ items: items, page: pagination})
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
|
res.status(500).json({ message: 'error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.get('/user/:user', async (req, res, next) => {
|
||||||
|
const account = req.user;
|
||||||
|
|
||||||
|
res.status(200).json({ id : account.id, name: account.username });
|
||||||
|
});
|
||||||
|
|
||||||
|
api.post('/user/:user/add_gm', async (req, res, next) => {
|
||||||
|
const account = req.user;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.update_account(account.id, {"gm" : true})
|
||||||
|
res.status(200).json({});
|
||||||
|
} catch(e) {
|
||||||
|
console.log(e);
|
||||||
|
res.status(500).json({ message: 'error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.post('/user/:user/remove_gm', async (req, res, next) => {
|
||||||
|
const account = req.user;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.update_account(account.id, {"gm" : false})
|
||||||
|
res.status(200).json({});
|
||||||
|
} catch(e) {
|
||||||
|
console.log(e);
|
||||||
|
res.status(500).json({ message: 'error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.post('/user/:user/ban', async (req, res, next) => {
|
||||||
|
const account = req.user;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// also drop GM if they had it...
|
||||||
|
await db.update_account(account.id, {"inactive" : true, "gm" : false})
|
||||||
|
res.status(200).json({});
|
||||||
|
} catch(e) {
|
||||||
|
console.log(e);
|
||||||
|
res.status(500).json({ message: 'error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.post('/user/:user/unban', async (req, res, next) => {
|
||||||
|
const account = req.user;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.update_account(account.id, {"inactive" : false})
|
||||||
|
res.status(200).json({});
|
||||||
|
} catch(e) {
|
||||||
|
console.log(e);
|
||||||
|
res.status(500).json({ message: 'error' });
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
api.get('/characters', async (req, res, next) => {
|
||||||
|
const pagination = get_pagination(req);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const characters = await db.get_characters(pagination, db.CHARACTER.LAST_LOGIN, db.SQL_ORDER.DESCENDING);
|
||||||
|
res.status(200).json({ characters: characters, page: pagination})
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
|
res.status(500).json({ message: 'error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default api;
|
||||||
69
api/authentication.js
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import express from 'express'
|
||||||
|
import * as db from './db.js'
|
||||||
|
|
||||||
|
const api = express.Router();
|
||||||
|
|
||||||
|
api.post('/register', async (req, res, next) => {
|
||||||
|
const rUsername = req.body.username;
|
||||||
|
const rPassword = req.body.password;
|
||||||
|
const rEmail = req.body.email; // TODO: validate email regex
|
||||||
|
// TODO: recaptcha, csrf protection
|
||||||
|
|
||||||
|
if (!rUsername || !rPassword || !rEmail) {
|
||||||
|
res.status(400).json({message: "missing fields"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: username regex
|
||||||
|
if (rUsername.length < 3) {
|
||||||
|
res.status(400).json({message: "Username must be at least 3 characters"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const account_id = await db.create_account(rUsername, rPassword);
|
||||||
|
req.session.account_id = account_id;
|
||||||
|
res.status(200).json({account_id: account_id});
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === 'UNIQUE_VIOLATION') {
|
||||||
|
res.status(400).json({message: 'Username already taken'});
|
||||||
|
} else {
|
||||||
|
console.log(e)
|
||||||
|
res.status(500).json({message: 'error'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.post('/login', async (req, res, next) => {
|
||||||
|
const rUsername = req.body.username;
|
||||||
|
const rPassword = req.body.password;
|
||||||
|
|
||||||
|
if (!rUsername || !rPassword) {
|
||||||
|
res.status(400).json({message: 'missing fields'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const account_id = await db.validate_account(rUsername, rPassword);
|
||||||
|
|
||||||
|
if (account_id === undefined) {
|
||||||
|
res.status(403).json({message: 'invalid username/password'});
|
||||||
|
} else {
|
||||||
|
req.session.account_id = account_id;
|
||||||
|
res.status(200).json({account_id: account_id});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
res.status(500).json({message: 'error'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.post('/logout', async (req, res, next) => {
|
||||||
|
if (req.session) {
|
||||||
|
req.session.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default api;
|
||||||
376
api/db.js
Normal file
|
|
@ -0,0 +1,376 @@
|
||||||
|
import pg from 'pg'
|
||||||
|
import pg_error from 'pg-error-constants'
|
||||||
|
import bcrypt from 'bcrypt'
|
||||||
|
import assert from 'assert'
|
||||||
|
|
||||||
|
function objectFlip(obj) {
|
||||||
|
const ret = {};
|
||||||
|
Object.keys(obj).forEach(key => {
|
||||||
|
ret[obj[key]] = key;
|
||||||
|
});
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pg_error_inv = objectFlip(pg_error)
|
||||||
|
|
||||||
|
export let pool;
|
||||||
|
|
||||||
|
const FACTION_MAP = {
|
||||||
|
0 : ["Terran Republic", "TR"],
|
||||||
|
1 : ["New Conglomerate", "NC"],
|
||||||
|
2 : ["Vanu Sovereignty", "VS"],
|
||||||
|
3 : ["Neutral", "NL"],
|
||||||
|
}
|
||||||
|
|
||||||
|
const FACTION_MAP_INV = objectFlip(FACTION_MAP)
|
||||||
|
const BCRYPT_ROUNDS = 4;
|
||||||
|
|
||||||
|
export const SQL_ORDER = Object.freeze({
|
||||||
|
ASCENDING: Symbol("ASC"),
|
||||||
|
DESCENDING: Symbol("DESC"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ACCOUNT = Object.freeze({
|
||||||
|
THIS: Symbol("accounts"),
|
||||||
|
ID: Symbol("id"),
|
||||||
|
USER: Symbol("username"),
|
||||||
|
PASSWORD: Symbol("passhash"),
|
||||||
|
CREATED: Symbol("created"),
|
||||||
|
MODIFIED: Symbol("last_modified"),
|
||||||
|
BANNED: Symbol("inactive"),
|
||||||
|
ADMIN: Symbol("gm"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CHARACTER = Object.freeze({
|
||||||
|
THIS: Symbol("characters"),
|
||||||
|
ID: Symbol("id"),
|
||||||
|
NAME: Symbol("name"),
|
||||||
|
ACCOUNT_ID: Symbol("account_id"),
|
||||||
|
FACTION: Symbol("faction_id"),
|
||||||
|
GENDER: Symbol("gender_id"),
|
||||||
|
HEAD: Symbol("head_id"),
|
||||||
|
VOICE: Symbol("void_id"),
|
||||||
|
CREATED: Symbol("created"),
|
||||||
|
LAST_LOGIN: Symbol("last_login"),
|
||||||
|
LAST_MODIFIED: Symbol("last_modified"),
|
||||||
|
DELETED: Symbol("deleted"),
|
||||||
|
});
|
||||||
|
|
||||||
|
function to_sql(symbol) {
|
||||||
|
assert(typeof symbol == 'symbol')
|
||||||
|
return String(symbol).slice(7,-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get_row_count(table) {
|
||||||
|
const resp = await pool.query(`SELECT COUNT(*) FROM ${to_sql(table)}`);
|
||||||
|
return parseInt(resp.rows[0].count);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connect_to_db() {
|
||||||
|
pool = new pg.Pool()
|
||||||
|
try {
|
||||||
|
const res = await pool.query('SELECT NOW()')
|
||||||
|
console.log(`Connected to the psql database at ${process.env.PGHOST}`)
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Unable to connect to the database: " + e.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get_account_by_id(id) {
|
||||||
|
try {
|
||||||
|
const account = await pool.query('SELECT * FROM accounts WHERE id=$1', [id]);
|
||||||
|
const account_obj = account.rows[0];
|
||||||
|
|
||||||
|
delete account_obj.passhash;
|
||||||
|
return account_obj;
|
||||||
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get_accounts(pagination, sort, order) {
|
||||||
|
const start_id = (pagination.page-1)*pagination.items_per_page;
|
||||||
|
const values = [start_id, pagination.items_per_page];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const account_count = await get_row_count(ACCOUNT.THIS);
|
||||||
|
const accounts = await pool.query(`SELECT id, username, created, last_modified, gm, inactive FROM accounts ORDER BY ${to_sql(sort)} ${to_sql(order)} OFFSET $1 LIMIT $2`, values);
|
||||||
|
|
||||||
|
pagination.item_count = account_count;
|
||||||
|
pagination.page_count = Math.ceil(pagination.item_count / pagination.items_per_page);
|
||||||
|
accounts.rows.forEach((r) => {
|
||||||
|
r.name = r.username;
|
||||||
|
r.admin = r.gm;
|
||||||
|
delete r.username;
|
||||||
|
delete r.gm;
|
||||||
|
});
|
||||||
|
return accounts.rows;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code)
|
||||||
|
e.code = pg_error_inv[e.code]
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get_accounts_login_info(pagination, sort, order) {
|
||||||
|
const start_id = (pagination.page-1)*pagination.items_per_page;
|
||||||
|
const values = [start_id, pagination.items_per_page];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const account_count = await get_row_count(ACCOUNT.THIS);
|
||||||
|
|
||||||
|
// this was a really hard query to get right...
|
||||||
|
// https://www.gab.lc/articles/better_faster_subqueries_postgresql/
|
||||||
|
const accounts = await pool.query(
|
||||||
|
'SELECT accounts.*, lastLogin as login_time, l2.ip_address, l2.canonical_hostname FROM accounts' +
|
||||||
|
' LEFT OUTER JOIN (' +
|
||||||
|
' SELECT MAX(id) as loginId, account_id, MAX(login_time) as lastLogin' +
|
||||||
|
' FROM logins' +
|
||||||
|
' GROUP BY account_id' +
|
||||||
|
' ) l ON l.account_id = accounts.id' +
|
||||||
|
' LEFT OUTER JOIN logins l2' +
|
||||||
|
' ON l2.id = l.loginId' +
|
||||||
|
` ORDER BY COALESCE(l.lastLogin, TIMESTAMP \'epoch\') ${to_sql(order)} OFFSET $1 LIMIT $2`, values);
|
||||||
|
|
||||||
|
pagination.item_count = account_count;
|
||||||
|
pagination.page_count = Math.ceil(pagination.item_count / pagination.items_per_page);
|
||||||
|
|
||||||
|
accounts.rows.forEach((r) => {
|
||||||
|
r.name = r.username;
|
||||||
|
r.admin = r.gm;
|
||||||
|
|
||||||
|
if (r.login_time !== null) {
|
||||||
|
r.last_login = {
|
||||||
|
time : r.login_time,
|
||||||
|
hostname : r.canonical_hostname,
|
||||||
|
ip : r.ip_address,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
r.last_login = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete r.login_time;
|
||||||
|
delete r.canonical_hostname;
|
||||||
|
delete r.ip_address;
|
||||||
|
delete r.passhash;
|
||||||
|
delete r.username;
|
||||||
|
delete r.gm;
|
||||||
|
});
|
||||||
|
return accounts.rows;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code)
|
||||||
|
e.code = pg_error_inv[e.code]
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get_characters(pagination, sort, order) {
|
||||||
|
const start_id = (pagination.page-1)*pagination.items_per_page;
|
||||||
|
const values = [start_id, pagination.items_per_page];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const char_count = await get_row_count(CHARACTER.THIS);
|
||||||
|
const chars = await pool.query(`SELECT id, name, faction_id, created, last_login FROM characters ORDER BY ${to_sql(sort)} ${to_sql(order)} OFFSET $1 LIMIT $2`, values);
|
||||||
|
|
||||||
|
pagination.item_count = char_count;
|
||||||
|
pagination.page_count = Math.ceil(pagination.item_count / pagination.items_per_page);
|
||||||
|
|
||||||
|
chars.rows.forEach((r) => {
|
||||||
|
delete r.account_id;
|
||||||
|
});
|
||||||
|
|
||||||
|
return chars.rows;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code)
|
||||||
|
e.code = pg_error_inv[e.code]
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get_characters_by_account(account_id) {
|
||||||
|
try {
|
||||||
|
const characters = await pool.query('SELECT * FROM characters WHERE account_id=$1 AND deleted=false', [account_id])
|
||||||
|
return characters.rows;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code)
|
||||||
|
e.code = pg_error_inv[e.code]
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get_account_by_name(name) {
|
||||||
|
try {
|
||||||
|
const account = await pool.query('SELECT * FROM accounts WHERE username=$1', [name]);
|
||||||
|
return account.rows[0];
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code)
|
||||||
|
e.code = pg_error_inv[e.code]
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function create_account(username, password) {
|
||||||
|
try {
|
||||||
|
const passhash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||||
|
const account_id = await pool.query('INSERT INTO accounts(username, passhash) VALUES($1, $2) RETURNING id', [username, passhash]);
|
||||||
|
return account_id.rows[0].id;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code)
|
||||||
|
e.code = pg_error_inv[e.code]
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function build_set(fields, idx=1) {
|
||||||
|
let SQL = []
|
||||||
|
let values = []
|
||||||
|
|
||||||
|
// TODO: sort for consistency
|
||||||
|
Object.keys(fields).forEach(key => {
|
||||||
|
SQL.push(key+"=$"+idx++)
|
||||||
|
values.push(fields[key])
|
||||||
|
});
|
||||||
|
|
||||||
|
return [SQL.join(", "), idx, values]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function update_account(account_id, fields) {
|
||||||
|
if (fields === {}) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const set = build_set(fields);
|
||||||
|
set[2].push(account_id)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const update_result = await pool.query('UPDATE accounts SET ' + set[0] + ' WHERE id=$'+set[1],set[2]);
|
||||||
|
return update_result.rowCount;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code)
|
||||||
|
e.code = pg_error_inv[e.code]
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get_stats() {
|
||||||
|
try {
|
||||||
|
const account_count = await get_row_count(ACCOUNT.THIS);
|
||||||
|
const character_count = await get_row_count(CHARACTER.THIS);
|
||||||
|
const last_account = await pool.query('SELECT id, username, created FROM accounts ORDER BY id DESC LIMIT 1');
|
||||||
|
const last_character = await pool.query('SELECT id, name, faction_id, created FROM characters ORDER BY id DESC LIMIT 1');
|
||||||
|
const empires = await pool.query('SELECT faction_id, COUNT(*) FROM characters GROUP BY faction_id');
|
||||||
|
|
||||||
|
const stats = {}
|
||||||
|
stats.accounts = account_count;
|
||||||
|
stats.characters = character_count;
|
||||||
|
stats.last = {};
|
||||||
|
stats.last.character = last_character.rows[0];
|
||||||
|
stats.last.account = last_account.rows[0];
|
||||||
|
stats.last.account.name = stats.last.account.username
|
||||||
|
delete stats.last.account.username;
|
||||||
|
stats.empires = {};
|
||||||
|
|
||||||
|
empires.rows.forEach((r) =>
|
||||||
|
stats.empires[FACTION_MAP[r.faction_id][1]] = parseInt(r.count)
|
||||||
|
);
|
||||||
|
return stats;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code)
|
||||||
|
e.code = pg_error_inv[e.code]
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get_account_logins(account_id, pagination) {
|
||||||
|
const start_id = (pagination.page-1)*pagination.items_per_page;
|
||||||
|
const values = [account_id, start_id, pagination.items_per_page];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const logins = await pool.query('SELECT * FROM logins WHERE account_id=$1 ORDER by login_time DESC ' +
|
||||||
|
` OFFSET $2 LIMIT $3`, values);
|
||||||
|
pagination.item_count = 100;
|
||||||
|
pagination.page_count = Math.ceil(pagination.item_count / pagination.items_per_page);
|
||||||
|
|
||||||
|
return logins.rows;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code)
|
||||||
|
e.code = pg_error_inv[e.code]
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function search(term, pagination) {
|
||||||
|
const start_id = (pagination.page-1)*pagination.items_per_page;
|
||||||
|
const values = ['%'+term.toUpperCase()+'%', start_id, pagination.items_per_page];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accounts = await pool.query('SELECT id, username, gm FROM accounts ' +
|
||||||
|
'WHERE upper(username) LIKE $1 '+
|
||||||
|
` ORDER BY username OFFSET $2 LIMIT $3`, values);
|
||||||
|
const characters = await pool.query('SELECT id, name, faction_id FROM characters ' +
|
||||||
|
'WHERE name LIKE $1 '+
|
||||||
|
` ORDER BY upper(name) OFFSET $2 LIMIT $3`, values);
|
||||||
|
|
||||||
|
pagination.item_count = 100;
|
||||||
|
pagination.page_count = Math.ceil(pagination.item_count / pagination.items_per_page);
|
||||||
|
|
||||||
|
const results = []
|
||||||
|
|
||||||
|
accounts.rows.forEach((r) => {
|
||||||
|
r.type = "account";
|
||||||
|
r.name = r.username;
|
||||||
|
r.admin = r.gm;
|
||||||
|
delete r.username;
|
||||||
|
delete r.gm;
|
||||||
|
|
||||||
|
results.push(r)
|
||||||
|
});
|
||||||
|
|
||||||
|
characters.rows.forEach((r) => {
|
||||||
|
r.type = "character";
|
||||||
|
results.push(r)
|
||||||
|
});
|
||||||
|
|
||||||
|
// sort by name
|
||||||
|
results.sort(function(a, b) {
|
||||||
|
var nameA = a.name.toUpperCase(); // ignore upper and lowercase
|
||||||
|
var nameB = b.name.toUpperCase(); // ignore upper and lowercase
|
||||||
|
if (nameA < nameB) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (nameA > nameB) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// names must be equal
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code)
|
||||||
|
e.code = pg_error_inv[e.code]
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validate_account(username, password) {
|
||||||
|
try {
|
||||||
|
const data = await pool.query('SELECT id, passhash FROM accounts WHERE username=$1', [username]);
|
||||||
|
|
||||||
|
if (data.rows.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
} else {
|
||||||
|
const creds = data.rows[0];
|
||||||
|
|
||||||
|
if (await bcrypt.compare(password, creds.passhash) === true) {
|
||||||
|
return creds.id;
|
||||||
|
} else {
|
||||||
|
return creds.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
68
api/index.js
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import express from 'express'
|
||||||
|
import bodyParser from 'body-parser'
|
||||||
|
import * as db from './db.js'
|
||||||
|
import api_auth from './authentication.js'
|
||||||
|
import api_user from './user.js'
|
||||||
|
import api_info from './info.js'
|
||||||
|
import api_admin from './admin.js'
|
||||||
|
|
||||||
|
const api = express.Router();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
const LAG = 200;
|
||||||
|
const LAG_JITTER = 100;
|
||||||
|
console.log("WARNING: development server simulated delay active")
|
||||||
|
api.use((req, res, next) => {
|
||||||
|
setTimeout(() => next(), LAG + (Math.random()-0.5)*LAG_JITTER)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sessionRequired(req, res, next) {
|
||||||
|
if (!req.session || !req.session.account_id) {
|
||||||
|
res.status(403).json({message: 'session required'})
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function adminRequired(req, res, next) {
|
||||||
|
if (!req.session || !req.session.account_id) {
|
||||||
|
res.status(403).json({message: 'admin required'})
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const account = await db.get_account_by_id(req.session.account_id);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
console.log("ERROR: failed to lookup account from session!")
|
||||||
|
res.status(500).json({message: 'error'});
|
||||||
|
} else {
|
||||||
|
if (account.gm === true && account.inactive === false) {
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
res.status(403).json({message : 'admin required'})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
|
res.status(500).json({message: 'error'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.use(bodyParser.json());
|
||||||
|
api.use(bodyParser.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
api.use(api_auth)
|
||||||
|
api.use(api_info)
|
||||||
|
api.use(sessionRequired, api_user)
|
||||||
|
api.use(adminRequired, api_admin)
|
||||||
|
|
||||||
|
api.post("/bad_route", async (req, res, next) => {
|
||||||
|
console.log("BAD APP ROUTE:", req.body.route)
|
||||||
|
res.status(200).json({message : 'received'})
|
||||||
|
});
|
||||||
|
|
||||||
|
api.all('*', function(req, res){
|
||||||
|
res.status(404).json({message : 'Unknown API route'});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default api;
|
||||||
16
api/info.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import express from 'express'
|
||||||
|
import * as db from './db.js'
|
||||||
|
|
||||||
|
const api = express.Router();
|
||||||
|
|
||||||
|
api.get('/stats', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const stats = await db.get_stats();
|
||||||
|
res.status(200).json({ ...stats });
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
res.status(500).json({ message : 'error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default api;
|
||||||
59
api/user.js
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import express from 'express'
|
||||||
|
import * as db from './db.js'
|
||||||
|
import { get_pagination, fetch_user_middleware } from './util.js'
|
||||||
|
|
||||||
|
const api = express.Router();
|
||||||
|
|
||||||
|
api.param("user", fetch_user_middleware);
|
||||||
|
|
||||||
|
api.get('/user', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const account = await db.get_account_by_id(req.session.account_id);
|
||||||
|
res.status(200).json({ id : account.id, name: account.username, admin : account.gm });
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
res.status(500).json({ message: 'error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.get('/user/profile', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const account = await db.get_account_by_id(req.session.account_id);
|
||||||
|
const characters = await db.get_characters_by_account(req.session.account_id);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
id : account.id,
|
||||||
|
name: account.username,
|
||||||
|
//email : account.email, // TODO
|
||||||
|
email : "bademail@email.com",
|
||||||
|
account_created : account.created,
|
||||||
|
admin : account.gm,
|
||||||
|
characters: characters,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
res.status(500).json({ message: 'error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
api.get('/user/:user/logins', async (req, res, next) => {
|
||||||
|
const account = req.user;
|
||||||
|
const pagination = get_pagination(req);
|
||||||
|
|
||||||
|
if (account.id !== req.session.account_id) {
|
||||||
|
res.status(403).json({ message: 'not allowed to see for other users' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const logins = await db.get_account_logins(account.id, pagination)
|
||||||
|
console.log(logins)
|
||||||
|
res.status(200).json({ logins : logins, page: pagination});
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
|
res.status(500).json({ message: 'error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default api;
|
||||||
32
api/util.js
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import * as db from './db.js'
|
||||||
|
|
||||||
|
export function get_pagination(req) {
|
||||||
|
let page = parseInt(req.query.page);
|
||||||
|
let order = req.query.order; // TODO
|
||||||
|
|
||||||
|
if (!page || page < 1) {
|
||||||
|
page = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: page,
|
||||||
|
items_per_page: 40,
|
||||||
|
//order: order, // TODO
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetch_user_middleware(req, res, next, id) {
|
||||||
|
try {
|
||||||
|
const account = await db.get_account_by_id(id);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
res.status(404).json({message: `account ${id} does not exist`});
|
||||||
|
} else {
|
||||||
|
req.user = account;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
res.status(500).json({message: 'error'});
|
||||||
|
}
|
||||||
|
}
|
||||||
106
app/App.svelte
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
<script>
|
||||||
|
// V1: Homepage (unauth, server list/status)
|
||||||
|
// V1: Logged in home page (character stats)
|
||||||
|
// - World server list with TR/VS/NC breakdown
|
||||||
|
// - Extra: Server stats
|
||||||
|
// - Extra: World map with empire colored grid
|
||||||
|
// - Extra: Instant action (psforever://server_name?charid=123&loc=123)
|
||||||
|
// V1: Profile (change pw, email)
|
||||||
|
// V1: Login (username, password)
|
||||||
|
// V1: Forgot password
|
||||||
|
// V1: Register (un, pw, email, captcha, email verification)
|
||||||
|
// Extra: Notifications / announcements
|
||||||
|
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import page from 'page';
|
||||||
|
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import { get_initial_state } from './UserState.js';
|
||||||
|
import Nav from './components/Nav.svelte'
|
||||||
|
import Alert from './components/Alert.svelte'
|
||||||
|
|
||||||
|
import Home from './views/Home.svelte';
|
||||||
|
import Login from './views/Login.svelte';
|
||||||
|
import Register from './views/Register.svelte';
|
||||||
|
import Profile from './views/Profile.svelte';
|
||||||
|
import BadRoute from './views/BadRoute.svelte';
|
||||||
|
import UserList from './views/UserList.svelte';
|
||||||
|
import AdminPanel from './views/AdminPanel.svelte';
|
||||||
|
import CharacterList from './views/CharacterList.svelte';
|
||||||
|
|
||||||
|
// prevent pop-in
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await get_initial_state()
|
||||||
|
initialized = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
let route;
|
||||||
|
let routeParams;
|
||||||
|
let pageCtx;
|
||||||
|
let appAlert;
|
||||||
|
|
||||||
|
let previousCtx = null
|
||||||
|
let currentCtx = null
|
||||||
|
|
||||||
|
//$ : console.log("INIT " + initialized, currentCtx.pageCtx)
|
||||||
|
|
||||||
|
function setRoute(r, initialState) {
|
||||||
|
return function(ctx) {
|
||||||
|
let first = !currentCtx
|
||||||
|
|
||||||
|
if (!first)
|
||||||
|
previousCtx = currentCtx
|
||||||
|
|
||||||
|
if (!first && currentCtx.route == r)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (appAlert)
|
||||||
|
appAlert.message("");
|
||||||
|
|
||||||
|
if (initialState !== undefined && initialState)
|
||||||
|
initialized = false;
|
||||||
|
else
|
||||||
|
initialized = true;
|
||||||
|
|
||||||
|
currentCtx = {
|
||||||
|
route : r,
|
||||||
|
routeParams : ctx.params,
|
||||||
|
pageCtx : ctx,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
page("/", setRoute(Home, true));
|
||||||
|
page("/login", setRoute(Login, true));
|
||||||
|
page("/register", setRoute(Register));
|
||||||
|
page("/register", setRoute(Register));
|
||||||
|
//page("/users", setRoute(UserList));
|
||||||
|
//page("/characters", setRoute(CharacterList));
|
||||||
|
page("/admin", setRoute(AdminPanel));
|
||||||
|
//page("/recovery", setRoute(Recovery));
|
||||||
|
page("/profile", setRoute(Profile, true));
|
||||||
|
page("*", setRoute(BadRoute));
|
||||||
|
page()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Nav bind:route={currentCtx.pageCtx.pathname}/>
|
||||||
|
|
||||||
|
<main role="main" class="container">
|
||||||
|
<Alert bind:this={appAlert} />
|
||||||
|
|
||||||
|
<div class:d-none={!initialized}>
|
||||||
|
<svelte:component this={currentCtx.route} bind:pageCtx={currentCtx.pageCtx} bind:ready={initialized} bind:appAlert={appAlert} bind:params={currentCtx.routeParams} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="container text-center">
|
||||||
|
<span class="text-muted">
|
||||||
|
©2019, PSForever.net, All Rights Reserved.<br/>
|
||||||
|
PlanetSide is a registered trademark of Daybreak Game Company, LLC. PSForever claims no such trademarks.<br/>
|
||||||
|
All other trademarks or tradenames are properties of their respective owners.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
52
app/UserState.js
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import page from 'page';
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
export const loggedIn = writable(false);
|
||||||
|
export const username = writable("");
|
||||||
|
export const isAdmin = writable(false);
|
||||||
|
export const userId = writable(0);
|
||||||
|
|
||||||
|
function clear_user_state() {
|
||||||
|
loggedIn.set(false)
|
||||||
|
username.set("")
|
||||||
|
isAdmin.set(false)
|
||||||
|
userId.set(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
try {
|
||||||
|
await axios.post("/api/logout")
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
|
||||||
|
clear_user_state();
|
||||||
|
page("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
loggedIn.subscribe((v) => {
|
||||||
|
console.log(loggedIn, v)
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function get_initial_state() {
|
||||||
|
try {
|
||||||
|
const resp = await axios.get("/api/user")
|
||||||
|
|
||||||
|
loggedIn.set(true);
|
||||||
|
username.set(resp.data.name);
|
||||||
|
isAdmin.set(resp.data.admin);
|
||||||
|
userId.set(resp.data.id);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.response.status === 403) {
|
||||||
|
console.log("User not logged in / not admin!")
|
||||||
|
clear_user_state();
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
console.log("Unknown login error", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/components/AccountLink.svelte
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<script>
|
||||||
|
import { isAdmin } from '../UserState'
|
||||||
|
export let account;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.banned-account {
|
||||||
|
color: red;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<span class="account-link">
|
||||||
|
|
||||||
|
{#if $isAdmin}
|
||||||
|
<a class:banned-account={account.inactive} href="/user/{account.id}">{account.name}</a>
|
||||||
|
{:else}
|
||||||
|
<span class:banned-account={account.inactive}>{account.name}</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if account.admin}
|
||||||
|
<span class="badge badge-success">GM</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
22
app/components/Alert.svelte
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<script>
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
|
export function message(e, ehtml) {
|
||||||
|
errorMessage = e ? e : "";
|
||||||
|
errorMessageHTML = ehtml ? ehtml : "";
|
||||||
|
|
||||||
|
shake = true;
|
||||||
|
setTimeout(() => {shake = false;}, 800)
|
||||||
|
}
|
||||||
|
|
||||||
|
let errorMessage = ""
|
||||||
|
let errorMessageHTML = ""
|
||||||
|
let shake = false;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if errorMessage || errorMessageHTML}
|
||||||
|
<div in:fade class:notification-shake={shake} class="alert alert-danger" role="alert">
|
||||||
|
{errorMessage}
|
||||||
|
{@html errorMessageHTML}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
20
app/components/CharacterLink.svelte
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<script>
|
||||||
|
import { isAdmin } from '../UserState'
|
||||||
|
export let character;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="character-link">
|
||||||
|
{#if character.faction_id == 1}
|
||||||
|
<img height=24 src="/img/nc_icon.png" alt="NC" />
|
||||||
|
{:else if character.faction_id == 0}
|
||||||
|
<img height=32 src="/img/tr_icon.png" alt="TR" />
|
||||||
|
{:else if character.faction_id == 2}
|
||||||
|
<img height=32 src="/img/vs_icon.png" alt="VS" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if $isAdmin}
|
||||||
|
<a href="/character/{character.id}">{character.name}</a>
|
||||||
|
{:else}
|
||||||
|
<span>{character.name}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
20
app/components/CharacterList.svelte
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import axios from 'axios'
|
||||||
|
import CharacterLink from '../components/CharacterLink'
|
||||||
|
import moment from 'moment'
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
axios.get("/api/user/"+userId+"/characters
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if characters}
|
||||||
|
{#each characters as char, i}
|
||||||
|
<ul>
|
||||||
|
<li><CharacterLink character={char} /></li>
|
||||||
|
</ul>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
89
app/components/EmpireStats.svelte
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { tweened } from 'svelte/motion';
|
||||||
|
import { cubicOut } from 'svelte/easing';
|
||||||
|
|
||||||
|
const progress = tweened(0, {
|
||||||
|
duration: 1000,
|
||||||
|
easing: cubicOut
|
||||||
|
});
|
||||||
|
|
||||||
|
export let stats = { "TR" : 0, "NC" : 0, "VS" : 0};
|
||||||
|
let total = stats.TR + stats.NC + stats.VS;
|
||||||
|
let percentages = { "TR" : stats.TR/total,
|
||||||
|
"NC" : stats.NC/total,
|
||||||
|
"VS" : stats.VS/total}
|
||||||
|
let tr, nc, vs;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
setTimeout(() => progress.set(1.0), 100);
|
||||||
|
|
||||||
|
tr.style.height = "1px";
|
||||||
|
nc.style.height = "1px";
|
||||||
|
vs.style.height = "1px";
|
||||||
|
})
|
||||||
|
|
||||||
|
progress.subscribe((v) => {
|
||||||
|
if (tr === undefined || !tr.style)
|
||||||
|
return
|
||||||
|
|
||||||
|
tr.style.height = v*percentages.TR*200 + "px";
|
||||||
|
nc.style.height = v*percentages.NC*200 + "px";
|
||||||
|
vs.style.height = v*percentages.VS*200 + "px";
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.empire-stats {
|
||||||
|
background: black;
|
||||||
|
height: 200px;
|
||||||
|
width: 220px;
|
||||||
|
border: 1px solid white;
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empire-stat {
|
||||||
|
border: 1px solid white;
|
||||||
|
border-bottom: 0;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
text-align: center;
|
||||||
|
min-width: 50px;
|
||||||
|
min-height: 3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empire-stat:nth-child(2) {
|
||||||
|
left: 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empire-stat:nth-child(3) {
|
||||||
|
left: 40.0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empire-stat:nth-child(4) {
|
||||||
|
left: 70.0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empire-stats-header {
|
||||||
|
display: inline;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 3px;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
background: white;
|
||||||
|
border-bottom: 3px solid black;
|
||||||
|
font-size: 1.0em;
|
||||||
|
color: black;
|
||||||
|
text-align: center;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="empire-stats clearfix">
|
||||||
|
<div class="empire-stats-header">Empire Need</div>
|
||||||
|
<div title={stats.TR} bind:this={tr} class="empire-stat faction-tr-bg"><strong>TR</strong><br/>{Math.round(percentages.TR*100*$progress)}%</div>
|
||||||
|
<div title={stats.NC} bind:this={nc} class="empire-stat faction-nc-bg"><strong>NC</strong><br/>{Math.round(percentages.NC*100*$progress)}%</div>
|
||||||
|
<div title={stats.VS} bind:this={vs} class="empire-stat faction-vs-bg"><strong>VS</strong><br/>{Math.round(percentages.VS*100*$progress)}%</div>
|
||||||
|
</div>
|
||||||
37
app/components/LoginList.svelte
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import PaginatedList from './PaginatedList'
|
||||||
|
import axios from 'axios'
|
||||||
|
import moment from 'moment'
|
||||||
|
|
||||||
|
export let account_id;
|
||||||
|
|
||||||
|
async function fetch(page) {
|
||||||
|
try {
|
||||||
|
const resp = await axios.get("/api/user/" + account_id + "/logins?page="+page);
|
||||||
|
return [resp.data.logins, resp.data.page];
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PaginatedList {fetch} let:data={logins} let:pagination={pagination}>
|
||||||
|
<table slot="body" class="table table-dark table-responsive">
|
||||||
|
<thead>
|
||||||
|
<td>Login Time</td>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{#each logins as login, i}
|
||||||
|
<tr>
|
||||||
|
<td>{moment(login.login_time).fromNow()}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</PaginatedList>
|
||||||
48
app/components/Nav.svelte
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<script>
|
||||||
|
import { get_initial_state, logout, isAdmin, loggedIn, username } from '../UserState.js';
|
||||||
|
import axios from 'axios'
|
||||||
|
export let route;
|
||||||
|
export let pageCtx;
|
||||||
|
console.log(pageCtx)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark justify-content-between">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand" href="/">
|
||||||
|
<img src="/img/logo_crop.png" height="30" class="d-inline-block align-top" alt="">
|
||||||
|
</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||||
|
<ul class="navbar-nav mr-auto">
|
||||||
|
<li class="nav-item" class:active={route=="/"}>
|
||||||
|
<a class="nav-link" href="/">Server Status</a>
|
||||||
|
</li>
|
||||||
|
{#if $isAdmin}
|
||||||
|
<li class="nav-item" class:active={route=="/admin"}>
|
||||||
|
<a class="nav-link" style="color: red;" href="/admin">Admin Panel</a>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
{#if $loggedIn}
|
||||||
|
<li class="nav-item" class:active={route=="/profile"}>
|
||||||
|
<span class="navbar-text">Welcome <a href="/profile">{$username}</a>!</span>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" on:click|preventDefault={logout} href="/logout">Logout</a>
|
||||||
|
</li>
|
||||||
|
{:else}
|
||||||
|
<li class="nav-item" class:active={route=="/login"}>
|
||||||
|
<a class="nav-link" href="/login">Login</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" class:active={route=="/register"}>
|
||||||
|
<a class="nav-link" href="/register">Register</a>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
57
app/components/PaginatedList.svelte
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import axios from 'axios'
|
||||||
|
import Pagination from '../components/Pagination'
|
||||||
|
|
||||||
|
export let fetch;
|
||||||
|
|
||||||
|
let data;
|
||||||
|
let fetching = false;
|
||||||
|
let pagination = { page: 1 };
|
||||||
|
|
||||||
|
export async function refresh() {
|
||||||
|
await list_fetch(pagination.page)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
let page = url.searchParams.get('page')
|
||||||
|
|
||||||
|
if (page == undefined)
|
||||||
|
page = 1;
|
||||||
|
|
||||||
|
await list_fetch(page);
|
||||||
|
})
|
||||||
|
|
||||||
|
async function pageChange(page) {
|
||||||
|
if (pagination.page == page || fetching)
|
||||||
|
return
|
||||||
|
|
||||||
|
await list_fetch(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function list_fetch(page) {
|
||||||
|
fetching = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fetch != undefined) {
|
||||||
|
const results = await fetch(page)
|
||||||
|
|
||||||
|
if (results != undefined) {
|
||||||
|
data = results[0];
|
||||||
|
pagination = results[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fetching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if data}
|
||||||
|
<slot name="header" data={data} pagination={pagination}></slot>
|
||||||
|
<Pagination {pagination} {pageChange} />
|
||||||
|
<slot name="body" data={data} pagination={pagination}></slot>
|
||||||
|
<Pagination {pagination} {pageChange} />
|
||||||
|
<slot name="footer" data={data} pagination={pagination}></slot>
|
||||||
|
{/if}
|
||||||
80
app/components/Pagination.svelte
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
<script>
|
||||||
|
export let pagination;
|
||||||
|
export let pageChange;
|
||||||
|
let numPages = 10;
|
||||||
|
let pages = []
|
||||||
|
|
||||||
|
$ : {
|
||||||
|
const new_pages = [];
|
||||||
|
let pi = 0, i;
|
||||||
|
let pg = pagination;
|
||||||
|
|
||||||
|
const pageChunk = Math.max(Math.ceil(numPages/3), 1);
|
||||||
|
const middleChunk = Math.max(Math.ceil(pageChunk/2), 1);
|
||||||
|
const leftBound = Math.min(pageChunk+1, pagination.page_count);
|
||||||
|
const rightBound = Math.max(pagination.page_count-pageChunk, 1);
|
||||||
|
|
||||||
|
// fast path: draw all pages
|
||||||
|
if (pg.page_count <= numPages || rightBound <= leftBound) {
|
||||||
|
for (i = 1; i <= pg.page_count; i++)
|
||||||
|
new_pages[pi++] = i;
|
||||||
|
} else {
|
||||||
|
let middleLeft = Math.max(pg.page-middleChunk, leftBound);
|
||||||
|
let middleRight = Math.min(pg.page+middleChunk, rightBound);
|
||||||
|
|
||||||
|
// left and middle chunks are joined
|
||||||
|
if (middleLeft == leftBound) {
|
||||||
|
middleLeft += 1;
|
||||||
|
middleRight = Math.min(middleLeft+pageChunk, rightBound);
|
||||||
|
// middle and right chunks are joined
|
||||||
|
} else if (middleRight == rightBound) {
|
||||||
|
middleRight -= 1;
|
||||||
|
middleLeft = Math.min(middleRight-middleChunk, rightBound);
|
||||||
|
}
|
||||||
|
|
||||||
|
//console.log("[1-"+leftBound+"]", "["+middleLeft+"-"+middleRight+"]", "["+rightBound+"-"+pagination.page_count+"]");
|
||||||
|
|
||||||
|
// left chunk
|
||||||
|
for (i = 1; i <= leftBound; i++) new_pages[pi++] = i;
|
||||||
|
if (leftBound+1 != middleLeft) new_pages[pi++] = -1;
|
||||||
|
|
||||||
|
// middle chunk
|
||||||
|
for (i = middleLeft; i <= middleRight; i++) new_pages[pi++] = i;
|
||||||
|
|
||||||
|
// right chunk
|
||||||
|
if (middleRight+1 != rightBound) new_pages[pi++] = -1;
|
||||||
|
for (i = rightBound; i <= pg.page_count; i++) new_pages[pi++] = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
pages = new_pages
|
||||||
|
//console.log(pages);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p>Displaying {(pagination.page-1)*pagination.items_per_page+1} — {Math.min(pagination.page*pagination.items_per_page, pagination.item_count)}</p>
|
||||||
|
|
||||||
|
<nav aria-label="Page navigation">
|
||||||
|
<ul class="pagination pagination-sm">
|
||||||
|
<li class="page-item" class:disabled={pagination.page<=1}>
|
||||||
|
<a class="page-link" href={"?page="+(pagination.page-1)}
|
||||||
|
on:click={(e) => pageChange(pagination.page-1)}
|
||||||
|
aria-label="Previous">
|
||||||
|
<span aria-hidden="true">«</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{#each pages as page,i}
|
||||||
|
{#if page == -1}
|
||||||
|
<li class="page-item page-last-separator disabled" ><a class="page-link">...<a></li>
|
||||||
|
{:else}
|
||||||
|
<li class="page-item" class:active={page==pagination.page}><a on:click={(e) => pageChange(page)} href={"?page="+page} class="page-link">{page}</a></li>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
<li class="page-item" class:disabled={pagination.page>=pagination.page_count}>
|
||||||
|
<a class="page-link" href={"?page="+(pagination.page+1)}
|
||||||
|
on:click={(e) => pageChange(pagination.page+1)}
|
||||||
|
aria-label="Next">
|
||||||
|
<span aria-hidden="true">»</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
10
app/main.js
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import 'bootstrap';
|
||||||
|
import '../scss/main.scss';
|
||||||
|
|
||||||
|
import App from './App.svelte';
|
||||||
|
|
||||||
|
const app = new App({
|
||||||
|
target: document.body
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
68
app/util/form.js
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
// Thanks to https://lengstorf.com/get-form-values-as-json/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks that an element has a non-empty `name` and `value` property.
|
||||||
|
* @param {Element} element the element to check
|
||||||
|
* @return {Bool} true if the element is an input, false if not
|
||||||
|
*/
|
||||||
|
const isValidElement = element => {
|
||||||
|
return element.name && element.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an element’s value can be saved (e.g. not an unselected checkbox).
|
||||||
|
* @param {Element} element the element to check
|
||||||
|
* @return {Boolean} true if the value should be added, false if not
|
||||||
|
*/
|
||||||
|
const isValidValue = element => {
|
||||||
|
return (!['checkbox', 'radio'].includes(element.type) || element.checked);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an input is a checkbox, because checkboxes allow multiple values.
|
||||||
|
* @param {Element} element the element to check
|
||||||
|
* @return {Boolean} true if the element is a checkbox, false if not
|
||||||
|
*/
|
||||||
|
const isCheckbox = element => element.type === 'checkbox';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an input is a `select` with the `multiple` attribute.
|
||||||
|
* @param {Element} element the element to check
|
||||||
|
* @return {Boolean} true if the element is a multiselect, false if not
|
||||||
|
*/
|
||||||
|
const isMultiSelect = element => element.options && element.multiple;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the selected options from a multi-select as an array.
|
||||||
|
* @param {HTMLOptionsCollection} options the options for the select
|
||||||
|
* @return {Array} an array of selected option values
|
||||||
|
*/
|
||||||
|
const getSelectValues = options => [].reduce.call(options, (values, option) => {
|
||||||
|
return option.selected ? values.concat(option.value) : values;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves input data from a form and returns it as a JSON object.
|
||||||
|
* @param {HTMLFormControlsCollection} elements the form elements
|
||||||
|
* @return {Object} form data as an object literal
|
||||||
|
*/
|
||||||
|
export const formToJSON = form => [].reduce.call(form.getElementsByTagName("*"), (data, element) => {
|
||||||
|
// Make sure the element has the required properties and should be added.
|
||||||
|
if (isValidElement(element) && isValidValue(element)) {
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Some fields allow for more than one value, so we need to check if this
|
||||||
|
* is one of those fields and, if so, store the values as an array.
|
||||||
|
*/
|
||||||
|
if (isCheckbox(element)) {
|
||||||
|
data[element.name] = (data[element.name] || []).concat(element.value);
|
||||||
|
} else if (isMultiSelect(element)) {
|
||||||
|
data[element.name] = getSelectValues(element);
|
||||||
|
} else {
|
||||||
|
data[element.name] = element.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}, {});
|
||||||
70
app/views/AdminPanel.svelte
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
<script>
|
||||||
|
import UserList from '../views/UserList'
|
||||||
|
import CharacterList from '../views/CharacterList'
|
||||||
|
import CharacterLink from '../components/CharacterLink'
|
||||||
|
import AccountLink from '../components/AccountLink'
|
||||||
|
import axios from 'axios'
|
||||||
|
export let appAlert;
|
||||||
|
|
||||||
|
let results;
|
||||||
|
async function submitSearch(event) {
|
||||||
|
const value = event.target.search.value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await axios.post("/api/search", { search : value })
|
||||||
|
results = resp.data.items;
|
||||||
|
} catch (e) {
|
||||||
|
appAlert.message(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>PSForever - Admin Panel</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1>Admin Panel</h1>
|
||||||
|
|
||||||
|
<!--<strong>Last account created:</strong> <AccountLink account={stats.last.account} /> (<span title={moment(stats.last.account.created).format(`MMMM Do YYYY, h:mm:ss a`)}>{moment(stats.last.account.created).fromNow()}</span>)<br/>-->
|
||||||
|
|
||||||
|
<ul class="nav nav-tabs mb-3" id="nav-tab" role="tablist">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" id="search-tab" data-toggle="tab" href="#search" role="tab" aria-controls="search" aria-selected="true">Search</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" id="users-tab" data-toggle="tab" href="#users" role="tab" aria-controls="home" aria-selected="false">Users</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" id="characters-tab" data-toggle="tab" href="#characters" role="tab" aria-controls="profile" aria-selected="false">Characters</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content" id="tabs-tabContent">
|
||||||
|
<div class="tab-pane fade show active" id="search" role="tabpanel" aria-labelledby="search-tab">
|
||||||
|
<form name="search" class="form-inline" on:submit|preventDefault={submitSearch}>
|
||||||
|
<div class="form-group mx-sm-3 mb-2">
|
||||||
|
<label for="inputSearch" class="sr-only">Search</label>
|
||||||
|
<input type="text" class="form-control" id="inputSearch" name="search" placeholder="Search" minlength=3 required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary mb-2">Search</button>
|
||||||
|
</form>
|
||||||
|
{#if results}
|
||||||
|
<ol>
|
||||||
|
{#each results as result, i}
|
||||||
|
{#if result.type == "account"}
|
||||||
|
<li><AccountLink account={result} /></li>
|
||||||
|
{:else if result.type == "character"}
|
||||||
|
<li><CharacterLink character={result} /></li>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="users" role="tabpanel" aria-labelledby="users-tab">
|
||||||
|
<UserList {appAlert} />
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="characters" role="tabpanel" aria-labelledby="characters-tab">
|
||||||
|
<CharacterList {appAlert} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
17
app/views/BadRoute.svelte
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import axios from 'axios'
|
||||||
|
export let params;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
await axios.post("/api/bad_route", {"route": params[0]})
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mt-3 text-center">
|
||||||
|
<img alt="404" src="/img/404.png" />
|
||||||
|
<h1>404 - PlanetSide Not Found</h1>
|
||||||
|
</div>
|
||||||
45
app/views/CharacterList.svelte
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import axios from 'axios'
|
||||||
|
import CharacterLink from '../components/CharacterLink'
|
||||||
|
import PaginatedList from '../components/PaginatedList'
|
||||||
|
import moment from 'moment'
|
||||||
|
|
||||||
|
export let appAlert
|
||||||
|
|
||||||
|
async function fetch(page) {
|
||||||
|
try {
|
||||||
|
const resp = await axios.get("/api/characters?page="+page)
|
||||||
|
appAlert.message("")
|
||||||
|
return [resp.data.characters, resp.data.page];
|
||||||
|
} catch (e) {
|
||||||
|
appAlert.message(e.message)
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PaginatedList bind:fetch={fetch} let:data={characters} let:pagination={pagination}>
|
||||||
|
<div slot="header">
|
||||||
|
<p>{pagination.item_count.toLocaleString()} characters in the database</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table slot="body" class="table table-dark table-responsive">
|
||||||
|
<thead>
|
||||||
|
<td>ID</td>
|
||||||
|
<td>Name</td>
|
||||||
|
<td>Last Played</td>
|
||||||
|
<td>Created</td>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each characters as char, i}
|
||||||
|
<tr>
|
||||||
|
<td>#{char.id}</td>
|
||||||
|
<td><CharacterLink character={char} /></td>
|
||||||
|
<td>{moment(char.last_login).fromNow()}</td>
|
||||||
|
<td>{moment(char.created).fromNow()}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</PaginatedList>
|
||||||
66
app/views/Home.svelte
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import axios from 'axios'
|
||||||
|
import moment from 'moment'
|
||||||
|
|
||||||
|
import { loggedIn } from '../UserState'
|
||||||
|
import Alert from '../components/Alert'
|
||||||
|
import AccountLink from '../components/AccountLink'
|
||||||
|
import CharacterLink from '../components/CharacterLink'
|
||||||
|
import EmpireStats from '../components/EmpireStats'
|
||||||
|
|
||||||
|
export let ready;
|
||||||
|
|
||||||
|
let stats;
|
||||||
|
let alert;
|
||||||
|
|
||||||
|
function format_account(account) {
|
||||||
|
return `<a href="/user/${account.id}">${account.username}</a>`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await axios.get("/api/stats")
|
||||||
|
stats = resp.data;
|
||||||
|
alert.message("")
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
|
alert.message("Failed to fetch stats from server")
|
||||||
|
}
|
||||||
|
|
||||||
|
ready = true
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>PSForever</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<Alert bind:this={alert} />
|
||||||
|
|
||||||
|
{#if stats}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-8">
|
||||||
|
<h1>PSForever Beta Server</h1>
|
||||||
|
<p>
|
||||||
|
<strong>Server address:</strong> <code>play.psforever.net:51200</code> (<a href="https://docs.google.com/document/d/1ZMx1NUylVZCXJNRyhkuVWT0eUKSVYu0JXsU-y3f93BY/edit">Setup Instructions</a>)<br/>
|
||||||
|
<strong>PSForever accounts:</strong> {stats.accounts.toLocaleString()}<br/>
|
||||||
|
<strong>Server characters:</strong> {stats.characters.toLocaleString()}<br/>
|
||||||
|
<strong>Last character created:</strong> <CharacterLink character={stats.last.character} /> (<span title={moment(stats.last.character.created).format(`MMMM Do YYYY, h:mm:ss a`)}>{moment(stats.last.character.created).fromNow()}</span>)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-4">
|
||||||
|
<EmpireStats stats={stats.empires} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{#if !$loggedIn}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<a class="btn btn-primary" href="/login" role="button">Login</a>
|
||||||
|
<a class="btn btn-primary" href="/register" role="button">Create Account</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
91
app/views/Login.svelte
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import axios from 'axios'
|
||||||
|
import page from 'page';
|
||||||
|
import Alert from '../components/Alert'
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import { formToJSON } from '../util/form.js'
|
||||||
|
import { get_initial_state } from '../UserState.js';
|
||||||
|
|
||||||
|
export let ready = false;
|
||||||
|
|
||||||
|
const redirect = (new URL(window.location.href)).searchParams.get('redirect')
|
||||||
|
let loginAttempts = 0;
|
||||||
|
let alert;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
// always check the initial state to see if we need to display this or not
|
||||||
|
if (await get_initial_state()) {
|
||||||
|
if (redirect)
|
||||||
|
page.redirect(redirect)
|
||||||
|
else
|
||||||
|
page.redirect("/")
|
||||||
|
|
||||||
|
} else {
|
||||||
|
ready = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submitLogin(e) {
|
||||||
|
try {
|
||||||
|
const resp = await axios.post("/api/login", formToJSON(event.target))
|
||||||
|
|
||||||
|
if (await get_initial_state()) {
|
||||||
|
if (redirect)
|
||||||
|
page.redirect(redirect)
|
||||||
|
else
|
||||||
|
page.redirect("/")
|
||||||
|
|
||||||
|
alert.message()
|
||||||
|
loginAttempts = 0;
|
||||||
|
} else {
|
||||||
|
alert.message("Unknown login failure")
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e.response) {
|
||||||
|
if (e.response.status >= 500) {
|
||||||
|
alert.message("Unknown server error. Contact an administrator if this persists.")
|
||||||
|
} else if (e.response.status === 403) {
|
||||||
|
loginAttempts++;
|
||||||
|
const badpass = "Bad username and/or password."
|
||||||
|
|
||||||
|
if (loginAttempts >= 5) {
|
||||||
|
alert.message(badpass, `If you cannot remember your credentials, <a href="/recovery">reset them</a>. Otherwise you risk being locked out.`)
|
||||||
|
} else {
|
||||||
|
alert.message(badpass)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert.message("Unknown server error status");
|
||||||
|
}
|
||||||
|
} else if (e.request) {
|
||||||
|
alert.message("Unknown server error. Contact an administrator if this persists.")
|
||||||
|
} else {
|
||||||
|
alert.message("Unknown request error: " + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>PSForever - Login</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<h1>Login to PSForever</h1>
|
||||||
|
|
||||||
|
<Alert bind:this={alert} />
|
||||||
|
|
||||||
|
<form name="login" class="form-group" on:submit|preventDefault={submitLogin}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="inputUsername">Username</label>
|
||||||
|
<input class="form-control" id="inputUsername" placeholder="Username" name="username" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="inputPassword">Password</label>
|
||||||
|
<input class="form-control" type="password" id="inputPassword" placeholder="Password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
<p><a href="/recovery">Forgot username/password?</a></p>
|
||||||
|
</main>
|
||||||
82
app/views/Profile.svelte
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import axios from 'axios'
|
||||||
|
import page from 'page'
|
||||||
|
import moment from 'moment'
|
||||||
|
import CharacterLink from '../components/CharacterLink'
|
||||||
|
import LoginList from '../components/LoginList'
|
||||||
|
|
||||||
|
export let ready;
|
||||||
|
ready = false;
|
||||||
|
|
||||||
|
let username;
|
||||||
|
let characters = [];
|
||||||
|
let createDate;
|
||||||
|
let isAdmin;
|
||||||
|
let email;
|
||||||
|
let account;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await axios.get("/api/user/profile")
|
||||||
|
account = resp.data;
|
||||||
|
username = resp.data.name;
|
||||||
|
characters = resp.data.characters;
|
||||||
|
createDate = moment(resp.data.account_created).format('MMMM Do YYYY, h:mm:ss a')
|
||||||
|
+ " (" + moment(resp.data.account_created).fromNow() + ")";
|
||||||
|
isAdmin = resp.data.admin;
|
||||||
|
email = resp.data.email;
|
||||||
|
|
||||||
|
ready = true
|
||||||
|
} catch (e) {
|
||||||
|
if (e.response && e.response.status == 403) {
|
||||||
|
page("/login?redirect=/profile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>PSForever - Profile</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1>Your Account</h1>
|
||||||
|
<form>
|
||||||
|
{#if isAdmin}
|
||||||
|
<strong class="color-red">You are a GM.</strong>
|
||||||
|
{/if}
|
||||||
|
<div class="form-group row">
|
||||||
|
<label for="staticUsername" class="col-sm-2 col-form-label">Username</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input type="text" readonly class="form-control-plaintext" id="staticUsername" bind:value={username}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group row">
|
||||||
|
<label for="staticEmail" class="col-sm-2 col-form-label">Email</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input type="text" readonly class="form-control-plaintext" id="staticEmail" bind:value={email}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group row">
|
||||||
|
<label for="staticAccountCreated" class="col-sm-2 col-form-label">Account Created</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input type="text" readonly class="form-control-plaintext" id="staticAccountCreated" bind:value={createDate}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2>Characters</h2>
|
||||||
|
{#if characters.length > 1}
|
||||||
|
<div class="row">
|
||||||
|
{#each characters as char, i}
|
||||||
|
<div class="col-md-4 col-12"><CharacterLink character={char} /></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p>You have no characters</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<h2>Logins</h2>
|
||||||
|
{#if account}
|
||||||
|
<LoginList account_id={account.id} />
|
||||||
|
{/if}
|
||||||
109
app/views/Register.svelte
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
<script>
|
||||||
|
import axios from 'axios'
|
||||||
|
import page from 'page';
|
||||||
|
import Alert from '../components/Alert'
|
||||||
|
import { formToJSON } from '../util/form.js'
|
||||||
|
import { get_initial_state } from '../UserState.js';
|
||||||
|
|
||||||
|
let alert;
|
||||||
|
let validated = false;
|
||||||
|
let email, password; // for validation
|
||||||
|
|
||||||
|
function validatePassword(e) {
|
||||||
|
// cant use cpassword here because svelte has not propagated the bound value yet
|
||||||
|
if (password !== e.target.value)
|
||||||
|
e.target.setCustomValidity("Passwords do not match")
|
||||||
|
else
|
||||||
|
e.target.setCustomValidity("")
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateEmail(e) {
|
||||||
|
if (email !== e.target.value)
|
||||||
|
e.target.setCustomValidity("Emails do not match")
|
||||||
|
else
|
||||||
|
e.target.setCustomValidity("")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitLogin(e) {
|
||||||
|
const data = formToJSON(e.target);
|
||||||
|
|
||||||
|
if (e.target.checkValidity() === false) {
|
||||||
|
validated = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
validated = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
delete data.cpassword
|
||||||
|
delete data.cemail
|
||||||
|
const resp = await axios.post("/api/register", data);
|
||||||
|
|
||||||
|
if (await get_initial_state()) {
|
||||||
|
page.redirect("/")
|
||||||
|
} else {
|
||||||
|
alert.message("Unknown login failure")
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e.response) {
|
||||||
|
if (e.response.status >= 500) {
|
||||||
|
alert.message("Unknown server error. Contact an administrator if this persists.")
|
||||||
|
} else if (e.response.status === 400) {
|
||||||
|
alert.message(e.response.data.message)
|
||||||
|
} else {
|
||||||
|
alert.message("Unknown server error status");
|
||||||
|
}
|
||||||
|
} else if (e.request) {
|
||||||
|
alert.message("Unknown server error. Contact an administrator if this persists.")
|
||||||
|
} else {
|
||||||
|
alert.message("Unknown request error: " + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>PSForever - Register</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<h1>Register for PSForever</h1>
|
||||||
|
|
||||||
|
<Alert bind:this={alert} />
|
||||||
|
|
||||||
|
<form name="login" class:was-validated={validated} class="form-group needs-validation" novalidate on:submit|preventDefault={submitLogin}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="inputUsername">Username</label>
|
||||||
|
<input class="form-control" id="inputUsername" placeholder="Username" name="username" minlength=3 pattern={String.raw`[A-Za-z0-9]{3,}`} required>
|
||||||
|
<small id="emailHelp" class="form-text text-muted">This is used to login via the game client, launcher, and web interface.</small>
|
||||||
|
<div class="invalid-feedback">
|
||||||
|
Usernames must be at least 3 characters long and not contain special characters or spaces.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group col">
|
||||||
|
<label for="inputPassword">Password</label>
|
||||||
|
<input bind:value={password} class="form-control" type="password" id="inputPassword" placeholder="Password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group col">
|
||||||
|
<label for="inputCPassword">Confim Password</label>
|
||||||
|
<input on:input={validatePassword} on:change={validatePassword} class="form-control" type="password" id="inputCPassword" placeholder="Confirm Password" name="cpassword" required>
|
||||||
|
<div class="invalid-feedback">Passwords must match.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group col">
|
||||||
|
<label for="inputEmail">Email</label>
|
||||||
|
<input bind:value={email} class="form-control" type="email" id="inputEmail" placeholder="Email" name="email" required>
|
||||||
|
<small id="emailHelp" class="form-text text-muted">Emails are used to help confirm and recover accounts.</small>
|
||||||
|
<div class="invalid-feedback">Please provide a valid email address.</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group col">
|
||||||
|
<label for="inputCEmail">Confirm Email</label>
|
||||||
|
<input on:input={validateEmail} on:change={validateEmail} class="form-control" type="email" id="inputCEmail" placeholder="Confirm Email" name="cemail" required>
|
||||||
|
<div class="invalid-feedback">Email addresses must match.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Join the fight!</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
4
app/views/User.svelte
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<script>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
150
app/views/UserList.svelte
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import axios from 'axios'
|
||||||
|
import AccountLink from '../components/AccountLink'
|
||||||
|
import PaginatedList from '../components/PaginatedList'
|
||||||
|
import Alert from '../components/Alert'
|
||||||
|
import moment from 'moment'
|
||||||
|
import jq from 'jquery'
|
||||||
|
|
||||||
|
export let appAlert
|
||||||
|
let modalAlert, userList;
|
||||||
|
|
||||||
|
onMount(() => setup_actions());
|
||||||
|
|
||||||
|
async function fetch(page) {
|
||||||
|
try {
|
||||||
|
const resp = await axios.get("/api/users?page="+page)
|
||||||
|
appAlert.message("")
|
||||||
|
return [resp.data.users, resp.data.page];
|
||||||
|
} catch (e) {
|
||||||
|
appAlert.message(e.message)
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup_actions() {
|
||||||
|
const modal = jq('#actionModal');
|
||||||
|
|
||||||
|
modal.on('hide.bs.modal', () => modalAlert.message(""));
|
||||||
|
modal.on('show.bs.modal', (event) => {
|
||||||
|
const button = jq(event.relatedTarget) // Button that triggered the modal
|
||||||
|
const username = button.data('account-name')
|
||||||
|
const account_id = button.data('account-id')
|
||||||
|
const action_type = button.data('action')
|
||||||
|
const action_name = button.text();
|
||||||
|
|
||||||
|
modal.find('.modal-title').text("Confirm " + action_name)
|
||||||
|
modal.find('.modal-body p').text("Are you sure you want to perform this action on \'" + username + "\'?")
|
||||||
|
|
||||||
|
const submit = modal.find('.modal-footer .btn-primary')
|
||||||
|
|
||||||
|
submit.text(action_name)
|
||||||
|
// remove ALL previous click handlers
|
||||||
|
submit.off()
|
||||||
|
|
||||||
|
submit.click(async (event) => {
|
||||||
|
submit.addClass("disabled")
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.post("/api/user/"+ account_id + "/" + action_type)
|
||||||
|
await userList.refresh()
|
||||||
|
modal.modal('hide')
|
||||||
|
} catch (e) {
|
||||||
|
modalAlert.message(e.message)
|
||||||
|
} finally {
|
||||||
|
submit.removeClass("disabled")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PaginatedList bind:this={userList} bind:fetch={fetch} let:data={users} let:pagination={pagination}>
|
||||||
|
<div slot="header">
|
||||||
|
<p>{pagination.item_count.toLocaleString()} users in the database</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table slot="body" class="table table-dark table-responsive">
|
||||||
|
<thead>
|
||||||
|
<td>ID</td>
|
||||||
|
<td>Username</td>
|
||||||
|
<td>User Created</td>
|
||||||
|
<td>Last Login</td>
|
||||||
|
<td>Actions</td>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each users as user, i}
|
||||||
|
<tr>
|
||||||
|
<td>#{user.id}</td>
|
||||||
|
<td><AccountLink account={user} /></td>
|
||||||
|
<td>{moment(user.created).fromNow()}</td>
|
||||||
|
<td>{#if user.last_login.time}
|
||||||
|
{moment(user.last_login.time).fromNow()}<br/>
|
||||||
|
<code>{user.last_login.hostname} - {user.last_login.ip}</code>
|
||||||
|
{:else}
|
||||||
|
Never logged in
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if user.inactive}
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-warning btn-sm"
|
||||||
|
data-action="unban"
|
||||||
|
data-account-id={user.id}
|
||||||
|
data-account-name={user.name}
|
||||||
|
data-toggle="modal"
|
||||||
|
data-target="#actionModal">Unban</button>
|
||||||
|
{:else}
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-danger btn-sm"
|
||||||
|
data-action="ban"
|
||||||
|
data-account-id={user.id}
|
||||||
|
data-account-name={user.name}
|
||||||
|
data-toggle="modal"
|
||||||
|
data-target="#actionModal">Ban</button>
|
||||||
|
{#if user.admin}
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-warning btn-sm"
|
||||||
|
data-action="remove_gm"
|
||||||
|
data-account-id={user.id}
|
||||||
|
data-account-name={user.name}
|
||||||
|
data-toggle="modal"
|
||||||
|
data-target="#actionModal">Remove GM</button>
|
||||||
|
{:else}
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-success btn-sm"
|
||||||
|
data-action="add_gm"
|
||||||
|
data-account-id={user.id}
|
||||||
|
data-account-name={user.name}
|
||||||
|
data-toggle="modal"
|
||||||
|
data-target="#actionModal">Make GM</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</PaginatedList>
|
||||||
|
|
||||||
|
<div class="modal fade" id="actionModal" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="exampleModalLabel">Perform Action</h5>
|
||||||
|
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<Alert bind:this={modalAlert} />
|
||||||
|
<p>Are you sure?</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-dismiss="modal">No</button>
|
||||||
|
<button type="button" class="btn btn-primary">Yes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
100
index.js
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import express from 'express'
|
||||||
|
import session from 'express-session'
|
||||||
|
import connectPg from 'connect-pg-simple'
|
||||||
|
import morgan from 'morgan'
|
||||||
|
import history from 'connect-history-api-fallback'
|
||||||
|
import dotenv from 'dotenv'
|
||||||
|
import api from './api/index.js'
|
||||||
|
import * as db from './api/db.js'
|
||||||
|
|
||||||
|
const envresult = dotenv.config();
|
||||||
|
|
||||||
|
if (envresult.error) {
|
||||||
|
const err = envresult.error;
|
||||||
|
|
||||||
|
if (err.code == 'ENOENT') {
|
||||||
|
console.log("FATAL: your .env file is missing!")
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
throw envresult.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 8080;
|
||||||
|
const MODE = process.env.NODE_ENV || 'development';
|
||||||
|
const BASE_URL = 'https://play.psforever.net';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// apache logging on web requests
|
||||||
|
if (MODE !== 'production') {
|
||||||
|
app.use(morgan('dev'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: recaptcha
|
||||||
|
// TODO: form csrf protection: https://github.com/expressjs/csurf
|
||||||
|
// TODO: login rate limiting: https://www.npmjs.com/package/rate-limiter-flexible
|
||||||
|
// - https://github.com/animir/node-rate-limiter-flexible/wiki/Overall-example#login-endpoint-protection
|
||||||
|
// TODO: X-Frame-Options: deny
|
||||||
|
// TODO: X-Upgrade-Insecure-Requests
|
||||||
|
|
||||||
|
// Kick off the DB connection and any dependencies
|
||||||
|
// Needs to be in a function to await the DB connection state
|
||||||
|
(async () => {
|
||||||
|
await db.connect_to_db();
|
||||||
|
|
||||||
|
const pgSession = connectPg(session);
|
||||||
|
|
||||||
|
const sessionMiddleware= session({
|
||||||
|
store: new pgSession({
|
||||||
|
pool : db.pool,
|
||||||
|
tableName : 'session'
|
||||||
|
}),
|
||||||
|
secret: process.env.COOKIE_SECRET, // changing this will invalidate all sessions
|
||||||
|
resave: false, // dont bother saving unchanged sessions
|
||||||
|
saveUninitialized: false, // dont bother saving sessions that have no data
|
||||||
|
cookie: {
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||||
|
//secure: true, // TODO: only send cookie over https
|
||||||
|
} // 7 days
|
||||||
|
})
|
||||||
|
|
||||||
|
// All API requests have a session. Other requests are static
|
||||||
|
app.use("/api", sessionMiddleware, api);
|
||||||
|
|
||||||
|
/*if (MODE === 'development') {
|
||||||
|
const webpack = await import('webpack')
|
||||||
|
const middleware = await import('webpack-dev-middleware')
|
||||||
|
const config = await import('./webpack.config.cjs')
|
||||||
|
const hot_webpack = await import('webpack-hot-middleware')
|
||||||
|
const hot_server_webpack = await import('webpack-hot-server-middleware')
|
||||||
|
const compiler = webpack.default(config.default)
|
||||||
|
app.use(middleware.default(compiler))
|
||||||
|
app.use(hot_webpack.default(compiler))
|
||||||
|
app.use(hot_server_webpack.default(compiler))
|
||||||
|
}*/
|
||||||
|
|
||||||
|
// TODO: inject csrf token into meta of index.html
|
||||||
|
app.use(express.static('public'));
|
||||||
|
|
||||||
|
// Redirect 404s to /index.html for the single-page app (SPA)
|
||||||
|
app.use(history());
|
||||||
|
|
||||||
|
// This last static might seem redundant, but it is necessary to have the history API
|
||||||
|
// redirect work properly. If the app makes it this far, it will be an Express 404
|
||||||
|
app.use(express.static('public'));
|
||||||
|
|
||||||
|
app.listen(PORT, function() {
|
||||||
|
let url = '';
|
||||||
|
|
||||||
|
if (MODE === 'development') {
|
||||||
|
url = 'http://localhost:' + PORT + '/';
|
||||||
|
} else {
|
||||||
|
url = BASE_URL + '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[MODE ' + MODE + '] PSFWeb now accepting requests at ' + url);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
59
package.json
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
{
|
||||||
|
"name": "psfwebui",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "A web interface to PSForever servers.",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "cross-env NODE_ENV=production webpack --config webpack.config.cjs",
|
||||||
|
"dev": "webpack-dev-server --history-api-fallback --config webpack.config.cjs --content-base public",
|
||||||
|
"dev-server": "nodemon -w api/ -w index.js"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/psforever/PSFWeb.git"
|
||||||
|
},
|
||||||
|
"author": "PSForever.net",
|
||||||
|
"license": "GPL-3.0-or-later",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/psforever/PSFWeb/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/psforever/PSFWeb#readme",
|
||||||
|
"dependencies": {
|
||||||
|
"bcrypt": "^3.0.7",
|
||||||
|
"connect-history-api-fallback": "^1.6.0",
|
||||||
|
"connect-pg-simple": "^6.0.1",
|
||||||
|
"dotenv": "^8.2.0",
|
||||||
|
"express": "^4.17.1",
|
||||||
|
"express-session": "^1.17.0",
|
||||||
|
"morgan": "^1.9.1",
|
||||||
|
"page": "^1.11.5",
|
||||||
|
"pg": "^7.15.1",
|
||||||
|
"pg-error-constants": "^1.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"autoprefixer": "^9.7.3",
|
||||||
|
"axios": "^0.19.0",
|
||||||
|
"bootstrap": "^4.4.1",
|
||||||
|
"cross-env": "^5.2.0",
|
||||||
|
"css-loader": "^2.1.1",
|
||||||
|
"jquery": "^3.4.1",
|
||||||
|
"mini-css-extract-plugin": "^0.6.0",
|
||||||
|
"moment": "^2.24.0",
|
||||||
|
"nodemon": "^2.0.2",
|
||||||
|
"popper.js": "^1.16.0",
|
||||||
|
"postcss-loader": "^3.0.0",
|
||||||
|
"precss": "^4.0.0",
|
||||||
|
"sass": "^1.24.0",
|
||||||
|
"sass-loader": "^8.0.0",
|
||||||
|
"serve": "^11.0.0",
|
||||||
|
"style-loader": "^0.23.1",
|
||||||
|
"svelte": "^3.0.0",
|
||||||
|
"svelte-loader": "2.13.3",
|
||||||
|
"webpack": "^4.30.0",
|
||||||
|
"webpack-cli": "^3.3.0",
|
||||||
|
"webpack-dev-server": "^3.3.1",
|
||||||
|
"webpack-hot-middleware": "^2.25.0",
|
||||||
|
"webpack-hot-server-middleware": "^0.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/favicon.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/img/404.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
public/img/logo_crop.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
public/img/nc.png
Normal file
|
After Width: | Height: | Size: 150 KiB |
BIN
public/img/nc_icon.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
public/img/nc_med.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
public/img/tr.png
Normal file
|
After Width: | Height: | Size: 457 KiB |
BIN
public/img/tr_icon.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/img/tr_med.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/img/vs.png
Normal file
|
After Width: | Height: | Size: 282 KiB |
BIN
public/img/vs_icon.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
public/img/vs_med.png
Normal file
|
After Width: | Height: | Size: 123 KiB |
17
public/index.html
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset='utf-8'>
|
||||||
|
<meta name='viewport' content='width=device-width,initial-scale=1'>
|
||||||
|
|
||||||
|
<title>Play PSForever</title>
|
||||||
|
|
||||||
|
<link rel='icon' type='image/png' href='/favicon.png'>
|
||||||
|
<link rel='stylesheet' href='/bundle.css'>
|
||||||
|
|
||||||
|
<script defer src='/bundle.js'></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
0
scss/custom.scss
Normal file
130
scss/defaults.scss
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
$bg-color: #171d3a;
|
||||||
|
|
||||||
|
$faction-vs: #440E62;
|
||||||
|
$faction-nc: #004B80;
|
||||||
|
$faction-tr: #9E0B0F;
|
||||||
|
|
||||||
|
.faction-vs {
|
||||||
|
color: $faction-vs;
|
||||||
|
}
|
||||||
|
.faction-nc {
|
||||||
|
color: $faction-nc;
|
||||||
|
}
|
||||||
|
.faction-tr {
|
||||||
|
color: $faction-tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faction-vs-bg {
|
||||||
|
background: $faction-vs;
|
||||||
|
}
|
||||||
|
.faction-nc-bg {
|
||||||
|
background: $faction-nc;
|
||||||
|
}
|
||||||
|
.faction-tr-bg {
|
||||||
|
background: $faction-tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: $bg-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sticky footer code
|
||||||
|
main {
|
||||||
|
min-height: calc(100vh - 160px);
|
||||||
|
margin: 20px 0;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
height: 60px;
|
||||||
|
font-size: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgb(0,150,250);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*a:visited {
|
||||||
|
color: rgb(0,160,190);
|
||||||
|
}*/
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, button, select, textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
padding: 0.4em;
|
||||||
|
margin: 0 0 0.5em 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:disabled {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"] {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
background-color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus {
|
||||||
|
border-color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control-plaintext {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-shake {
|
||||||
|
animation: shake 0.82s cubic-bezier(.36,.07,.19,.97) both;
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
10%, 90% {
|
||||||
|
transform: translate3d(-1px, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
20%, 80% {
|
||||||
|
transform: translate3d(2px, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
30%, 50%, 70% {
|
||||||
|
transform: translate3d(-4px, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
40%, 60% {
|
||||||
|
transform: translate3d(4px, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
4
scss/main.scss
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
// bootstrap style overrides
|
||||||
|
@import "custom";
|
||||||
|
@import "~bootstrap/scss/bootstrap";
|
||||||
|
@import "defaults";
|
||||||
80
webpack.config.cjs
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const mode = process.env.NODE_ENV || 'development';
|
||||||
|
const prod = mode === 'production';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
entry: {
|
||||||
|
bundle: ['./app/main.js']
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
svelte: path.resolve('node_modules', 'svelte')
|
||||||
|
},
|
||||||
|
extensions: ['.mjs', '.js', '.svelte'],
|
||||||
|
mainFields: ['svelte', 'browser', 'module', 'main']
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
path: __dirname + '/public',
|
||||||
|
filename: '[name].js',
|
||||||
|
chunkFilename: '[name].[id].js'
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.svelte$/,
|
||||||
|
use: {
|
||||||
|
loader: 'svelte-loader',
|
||||||
|
options: {
|
||||||
|
emitCss: true,
|
||||||
|
hotReload: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.css$/,
|
||||||
|
use: [
|
||||||
|
/**
|
||||||
|
* MiniCssExtractPlugin doesn't support HMR.
|
||||||
|
* For developing, use 'style-loader' instead.
|
||||||
|
* */
|
||||||
|
prod ? MiniCssExtractPlugin.loader : 'style-loader',
|
||||||
|
'css-loader'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.(scss)$/,
|
||||||
|
use: [{
|
||||||
|
loader: 'style-loader', // inject CSS to page
|
||||||
|
}, {
|
||||||
|
loader: 'css-loader', // translates CSS into CommonJS modules
|
||||||
|
}, {
|
||||||
|
loader: 'postcss-loader', // Run post css actions
|
||||||
|
options: {
|
||||||
|
plugins: function () { // post css plugins, can be exported to postcss.config.js
|
||||||
|
return [
|
||||||
|
require('precss'),
|
||||||
|
require('autoprefixer')
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
loader: 'sass-loader' // compiles Sass to CSS
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
mode,
|
||||||
|
plugins: [
|
||||||
|
new MiniCssExtractPlugin({
|
||||||
|
filename: '[name].css'
|
||||||
|
})
|
||||||
|
],
|
||||||
|
devServer: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8080'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
devtool: prod ? false: 'source-map'
|
||||||
|
};
|
||||||