Enable foreign profile view

This commit is contained in:
Chord 2019-12-30 13:20:50 -05:00
parent 20946fdb43
commit cb918033a6
14 changed files with 248 additions and 125 deletions

View file

@ -47,7 +47,7 @@ api.post('/user/:user/add_gm', async (req, res, next) => {
const account = req.user;
try {
await db.update_account(account.id, {"gm" : true})
await db.update_account(account.id, {[db.ACCOUNT.ADMIN] : true})
res.status(200).json({});
} catch(e) {
console.log(e);
@ -59,7 +59,7 @@ api.post('/user/:user/remove_gm', async (req, res, next) => {
const account = req.user;
try {
await db.update_account(account.id, {"gm" : false})
await db.update_account(account.id, {[db.ACCOUNT.ADMIN] : false})
res.status(200).json({});
} catch(e) {
console.log(e);
@ -72,7 +72,7 @@ api.post('/user/:user/ban', async (req, res, next) => {
try {
// also drop GM if they had it...
await db.update_account(account.id, {"inactive" : true, "gm" : false})
await db.update_account(account.id, {[db.ACCOUNT.BANNED] : true, [db.ACCOUNT.ADMIN] : false})
res.status(200).json({});
} catch(e) {
console.log(e);
@ -84,10 +84,9 @@ api.post('/user/:user/unban', async (req, res, next) => {
const account = req.user;
try {
await db.update_account(account.id, {"inactive" : false})
await db.update_account(account.id, {[db.ACCOUNT.BANNED] : false})
res.status(200).json({});
} catch(e) {
console.log(e);
res.status(500).json({ message: 'error' });
}

View file

@ -56,13 +56,59 @@ export const CHARACTER = Object.freeze({
DELETED: Symbol("deleted"),
});
export const LOGIN = Object.freeze({
THIS: Symbol("logins"),
ID: Symbol("id"),
ACCOUNT_ID: Symbol("account_id"),
});
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)}`);
function to_sql_kv(fields, idx=1) {
let SQL = [];
let values = [];
// This will ONLY get Symbols in the field dict
assert(Object.getOwnPropertySymbols(fields).length > 0, "to_sql_kv must have at least one field")
Object.getOwnPropertySymbols(fields).forEach(key => {
assert(typeof key == 'symbol')
SQL.push(to_sql(key)+"=$"+idx++);
values.push(fields[key]);
});
return {
sql: SQL,
next_idx: idx,
values: values,
}
}
function build_SET(fields, idx=1) {
const kv = to_sql_kv(fields, idx);
kv.sql = Symbol(kv.sql.join(", "));
return kv;
}
function build_WHERE(fields, idx=1) {
const kv = to_sql_kv(fields, idx);
kv.sql = Symbol(kv.sql.join(" AND "));
return kv;
}
async function get_row_count(table, filter=undefined) {
let resp;
if (filter) {
const where = build_WHERE(filter);
resp = await pool.query(`SELECT COUNT(*) FROM ${to_sql(table)} WHERE ${to_sql(where.sql)}`,
where.values);
} else {
resp = await pool.query(`SELECT COUNT(*) FROM ${to_sql(table)}`);
}
return parseInt(resp.rows[0].count);
}
@ -70,6 +116,13 @@ export async function connect_to_db() {
pool = new pg.Pool()
try {
const res = await pool.query('SELECT NOW()')
// Quick hack for query debugging (throws exception)
const _query = pool.query;
pool.query_log = (q, v) => {
console.log("QUERY LOG: ", q, v);
return _query(q, v);
}
console.log(`Connected to the psql database at ${process.env.PGHOST}`)
} catch (e) {
console.log("Unable to connect to the database: " + e.message);
@ -80,9 +133,14 @@ export async function connect_to_db() {
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];
if (account.rows.length == 0) {
return undefined;
}
const account_obj = account.rows[0];
delete account_obj.passhash;
return account_obj;
} catch (e) {
throw e;
@ -222,29 +280,16 @@ export async function create_account(username, password) {
}
}
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)
const set = build_SET(fields);
set.values.push(account_id)
try {
const update_result = await pool.query('UPDATE accounts SET ' + set[0] + ' WHERE id=$'+set[1],set[2]);
const update_result = await pool.query(`UPDATE accounts SET ${to_sql(set.sql)} WHERE id=$${set.next_idx}`, set.values);
return update_result.rowCount;
} catch (e) {
if (e.code)
@ -287,9 +332,10 @@ export async function get_account_logins(account_id, pagination) {
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;
const login_count = await get_row_count(LOGIN.THIS, { [LOGIN.ACCOUNT_ID] : account_id });
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 = login_count;
pagination.page_count = Math.ceil(pagination.item_count / pagination.items_per_page);
return logins.rows;

View file

@ -20,13 +20,6 @@ if (process.env.NODE_ENV !== "production") {
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);
@ -35,11 +28,8 @@ async function adminRequired(req, res, next) {
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'})
}
req.session_account = account;
next();
}
} catch (e) {
console.log(e)
@ -47,6 +37,18 @@ async function adminRequired(req, res, next) {
}
}
}
async function adminRequired(req, res, next) {
if (!req.session_account) {
console.log("ERROR: sessionRequired needs to be called before adminRequired")
res.status(500).json({message: ''})
} else {
if (req.session_account.gm === true && req.session_account.inactive === false) {
next();
} else {
res.status(403).json({message : 'admin required'})
}
}
}
api.use(bodyParser.json());
api.use(bodyParser.urlencoded({ extended: true }));
@ -54,7 +56,7 @@ 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.use(sessionRequired, adminRequired, api_admin)
api.post("/bad_route", async (req, res, next) => {
console.log("BAD APP ROUTE:", req.body.route)

View file

@ -16,10 +16,17 @@ api.get('/user', async (req, res, next) => {
}
});
api.get('/user/profile', async (req, res, next) => {
api.get('/user/:user/profile', async (req, res, next) => {
const target_account = req.user;
if (target_account.id !== req.session.account_id && !req.session_account.gm) {
res.status(403).json({ message: 'not allowed to see for other users' });
return;
}
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);
const account = await db.get_account_by_id(target_account.id);
const characters = await db.get_characters_by_account(target_account.id);
res.status(200).json({
id : account.id,
@ -36,19 +43,17 @@ api.get('/user/profile', async (req, res, next) => {
}
});
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) {
if (account.id !== req.session.account_id && !req.session_account.gm) {
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)

View file

@ -16,6 +16,13 @@ export function get_pagination(req) {
}
export async function fetch_user_middleware(req, res, next, id) {
id = parseInt(id);
if (id <= 0) {
res.status(500).json({message: 'error'});
return;
}
try {
const account = await db.get_account_by_id(id);

View file

@ -14,11 +14,10 @@
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';
@ -28,12 +27,12 @@ import UserList from './views/UserList.svelte';
import AdminPanel from './views/AdminPanel.svelte';
import CharacterList from './views/CharacterList.svelte';
// prevent pop-in
// prevent view pop-in
let initialized = false;
onMount(async () => {
await get_initial_state()
initialized = true;
page() // start the router
});
let route;
@ -53,9 +52,18 @@ function setRoute(r, initialState) {
if (!first)
previousCtx = currentCtx
if (!first && currentCtx.route == r)
if (!first && ctx.pathname == previousCtx.pageCtx.pathname)
return
if (process.env.NODE_ENV !== "production" && !first) {
console.log("-------------------NEW ROUTE--------------\n",
previousCtx.pageCtx.pathname, " -> ", ctx.pathname)
if (previousCtx && r == previousCtx.route)
console.log("/!\\ Re-rendering same view with different params")
}
// We are changing views, clear the global app message
if (appAlert)
appAlert.message("");
@ -64,27 +72,39 @@ function setRoute(r, initialState) {
else
initialized = true;
// Change the component context
currentCtx = {
route : r,
routeParams : ctx.params,
pageCtx : ctx,
}
/* If the previous view compoent was the same, we need to
force svelte to rerender it if some of the parameters have changed.
For example, clicking from another User's view to ourself wont lead
to a render due to the component not changing. We force this change
by scheduling the two changes on separate ticks.
*/
if (previousCtx && r == previousCtx.route) {
setImmediate(() => currentCtx.route = null)
setImmediate(() => currentCtx.route = r)
}
};
}
page("/", setRoute(Home, true));
page("/login", setRoute(Login, true));
page("/register", setRoute(Register));
page("/register", setRoute(Register));
page("/admin", setRoute(AdminPanel));
page("/profile", setRoute(Profile, true));
page("/user/:id", setRoute(Profile, true));
//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>
{#if currentCtx}
<Nav bind:route={currentCtx.pageCtx.pathname}/>
<main role="main" class="container">
@ -104,3 +124,4 @@ All other trademarks or tradenames are properties of their respective owners.
</span>
</div>
</footer>
{/if}

View file

@ -24,9 +24,11 @@ export async function logout() {
page("/")
}
loggedIn.subscribe((v) => {
console.log(loggedIn, v)
})
if (process.env.NODE_ENV !== "production") {
loggedIn.subscribe((v) => {
console.log("Login state: ", v)
})
}
export async function get_initial_state() {
try {
@ -42,7 +44,6 @@ export async function get_initial_state() {
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)
}

View file

@ -13,7 +13,7 @@
{/if}
{#if $isAdmin}
<a href="/character/{character.id}">{character.name}</a>
<a href="/user/{character.account_id}">{character.name}</a>
{:else}
<span>{character.name}</span>
{/if}

View file

@ -4,6 +4,7 @@
import { cubicOut } from 'svelte/easing';
const progress = tweened(0, {
delay: 100,
duration: 1000,
easing: cubicOut
});
@ -16,21 +17,22 @@
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 || !nc || !vs)
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";
})
setTimeout(() => progress.set(1.0), 100);
})
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>

View file

@ -21,15 +21,26 @@
</script>
<PaginatedList {fetch} let:data={logins} let:pagination={pagination}>
<p slot="header">
{#if pagination.item_count}
Login data
{:else}
No logins yet
{/if}
</p>
<table slot="body" class="table table-dark table-responsive">
<thead>
<td>Login Time</td>
<td>From</td>
<td>Login Date</td>
</thead>
<tbody>
{#each logins as login, i}
<tr>
<td>{moment(login.login_time).fromNow()}</td>
<td>
<code>{login.hostname} - {login.ip_address}</code>
</td>
<td>{moment(login.login_time).format('MMMM Do YYYY, h:mm:ss a')} ({moment(login.login_time).fromNow()})</td>
</tr>
{/each}
</tbody>

View file

@ -2,8 +2,6 @@
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">

View file

@ -3,6 +3,7 @@
import axios from 'axios'
import Pagination from '../components/Pagination'
export let setURLParam = false;
export let fetch;
let data;
@ -15,12 +16,16 @@
onMount(async () => {
const url = new URL(window.location.href)
let page = url.searchParams.get('page')
let initialPage = 1;
if (page == undefined)
page = 1;
if (setURLParam) {
let param = parseInt(url.searchParams.get('page'))
await list_fetch(page);
if (param != NaN)
initialPage = param;
}
await list_fetch(initialPage);
})
async function pageChange(page) {
@ -50,8 +55,10 @@
{#if data}
<slot name="header" data={data} pagination={pagination}></slot>
<Pagination {pagination} {pageChange} />
{#if pagination.item_count > 0}
<Pagination {pagination} {pageChange} {setURLParam} />
<slot name="body" data={data} pagination={pagination}></slot>
<Pagination {pagination} {pageChange} />
<Pagination {pagination} {pageChange} {setURLParam} />
{/if}
<slot name="footer" data={data} pagination={pagination}></slot>
{/if}

View file

@ -1,21 +1,31 @@
<script>
export let pagination;
export let pageChange;
let numPages = 10;
export let setURLParam = false;
export let displayPages = 10;
let pages = []
function pageClick(event) {
const page = event.target.getAttribute('data-page');
pageChange(parseInt(page))
if (!setURLParam)
event.preventDefault()
}
$ : {
const new_pages = [];
let pi = 0, i;
let pg = pagination;
const pageChunk = Math.max(Math.ceil(numPages/3), 1);
const pageChunk = Math.max(Math.ceil(displayPages/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) {
if (pg.page_count <= displayPages || rightBound <= leftBound) {
for (i = 1; i <= pg.page_count; i++)
new_pages[pi++] = i;
} else {
@ -54,27 +64,31 @@
<p>Displaying {(pagination.page-1)*pagination.items_per_page+1} &mdash; {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">&laquo;</span>
</a>
</li>
{#each pages as page,i}
<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={pageClick}
data-page={pagination.page-1}
aria-label="Previous">
&laquo;
</a>
</li>
{#each pages as page,i}
{#if page == -1}
<li class="page-item page-last-separator disabled" ><a class="page-link">...<a></li>
<li class="page-item page-last-separator disabled"><span class="page-link">...</span></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>
<li class="page-item" class:active={page==pagination.page}>
<a on:click={pageClick} href={"?page="+page} data-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">&raquo;</span>
</a>
</li>
</ul>
{/each}
<li class="page-item" class:disabled={pagination.page>=pagination.page_count}>
<a class="page-link" href={"?page="+(pagination.page+1)}
data-page={pagination.page+1}
on:click={pageClick}
aria-label="Next">
&raquo;
</a>
</li>
</ul>
</nav>

View file

@ -4,9 +4,15 @@
import page from 'page'
import moment from 'moment'
import CharacterLink from '../components/CharacterLink'
import { userId } from '../UserState'
import LoginList from '../components/LoginList'
import AccountLink from '../components/AccountLink'
export let pageCtx;
export let appAlert;
export let params;
export let ready;
ready = false;
let username;
@ -17,8 +23,10 @@
let account;
onMount(async () => {
let loadID = params.id || $userId;
try {
const resp = await axios.get("/api/user/profile")
const resp = await axios.get("/api/user/"+loadID+"/profile")
account = resp.data;
username = resp.data.name;
characters = resp.data.characters;
@ -30,7 +38,13 @@
ready = true
} catch (e) {
if (e.response && e.response.status == 403) {
page("/login?redirect=/profile")
if (e.response.data.message == "session required") {
page("/login?redirect="+pageCtx.pathname)
} else {
appAlert.message(e.response.data.message)
}
} else {
appAlert.message(e.message)
}
}
});
@ -40,17 +54,9 @@
<title>PSForever - Profile</title>
</svelte:head>
<h1>Your Account</h1>
{#if account}
<h1>Account: <AccountLink account={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">
@ -66,17 +72,21 @@
</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}
<p>
{#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}
You have no characters
{/if}
</p>
<h2>Logins</h2>
{#if account}
<p>
<LoginList account_id={account.id} />
</p>
{/if}