add initial concept of "superuser" accounts

This commit is contained in:
hippoz 2022-08-03 02:34:15 +03:00
parent 314a7f2be0
commit 6d76dec265
Signed by: hippoz
GPG key ID: 7C52899193467641
14 changed files with 134 additions and 12 deletions

View file

@ -17,7 +17,7 @@ function serve() {
return { return {
writeBundle() { writeBundle() {
if (server) return; if (server) return;
server = require('child_process').spawn('npm', ['run', 'start', '--', '--dev'], { server = require('child_process').spawn('yarn', ['run', 'start', '--', '--dev'], {
stdio: ['ignore', 'inherit', 'inherit'], stdio: ['ignore', 'inherit', 'inherit'],
shell: true shell: true
}); });

View file

@ -50,7 +50,7 @@
<div class="message"> <div class="message">
<span class="author">{ message.author_username }</span> <span class="author">{ message.author_username }</span>
<span class="message-content" class:pending={ message._isPending }>{ message.content }</span> <span class="message-content" class:pending={ message._isPending }>{ message.content }</span>
{#if userInfoStore.value && message.author_id === userInfoStore.value.id} {#if userInfoStore.value && (message.author_id === userInfoStore.value.id || userInfoStore.value.is_superuser)}
<button class="icon-button icon-button-auto edit-message" on:click="{ () => overlayStore.open('editMessage', { message }) }"> <button class="icon-button icon-button-auto edit-message" on:click="{ () => overlayStore.open('editMessage', { message }) }">
<MoreVerticalIcon /> <MoreVerticalIcon />
</button> </button>

View file

@ -42,7 +42,7 @@
{#if channel._hasUnreads} {#if channel._hasUnreads}
<span class="unread-indicator"></span> <span class="unread-indicator"></span>
{/if} {/if}
{#if $userInfoStore && channel.owner_id === $userInfoStore.id} {#if $userInfoStore && (channel.owner_id === $userInfoStore.id || $userInfoStore.is_superuser)}
<button class="icon-button icon-button-auto" on:click|stopPropagation="{ () => overlayStore.open('editChannel', { channel }) }"> <button class="icon-button icon-button-auto" on:click|stopPropagation="{ () => overlayStore.open('editChannel', { channel }) }">
<MoreVerticalIcon /> <MoreVerticalIcon />
</button> </button>

View file

@ -7,6 +7,7 @@
import CreateAccount from "./CreateAccount.svelte"; import CreateAccount from "./CreateAccount.svelte";
import EditMessage from "./EditMessage.svelte"; import EditMessage from "./EditMessage.svelte";
import Settings from "./Settings.svelte"; import Settings from "./Settings.svelte";
import Prompt from "./Prompt.svelte";
</script> </script>
{#if $overlayStore.createChannel} {#if $overlayStore.createChannel}
@ -30,3 +31,6 @@
{#if $overlayStore.settings} {#if $overlayStore.settings}
<Settings /> <Settings />
{/if} {/if}
{#if $overlayStore.prompt}
<Prompt { ...$overlayStore.prompt } />
{/if}

View file

@ -0,0 +1,53 @@
<script>
import { maybeFade, maybeFly } from "../../animations";
import { quintInOut } from "svelte/easing";
import { overlayStore } from "../../stores";
export let onSubmit = async () => {};
export let onClose = async () => {};
export let heading = "Prompt";
export let valueName = "Value";
let userInput = "";
let buttonsEnabled = true;
const close = async () => {
await onClose();
overlayStore.close("prompt");
};
const save = async () => {
buttonsEnabled = false;
await onSubmit(userInput);
close();
};
const onKeydown = async (e) => {
if (e.code !== "Enter")
return;
await save();
};
</script>
<style>
.full-width {
width: 100%;
}
</style>
<div class="modal-backdrop" transition:maybeFade="{{ duration: 300, easing: quintInOut }}" on:click="{ close }" on:keydown="{ onKeydown }">
<div class="modal" transition:maybeFly="{{ duration: 300, easing: quintInOut, y: 10 }}" on:click|stopPropagation>
<div class="modal-header">
<span class="h4">{ heading }</span>
</div>
<label class="input-label">
{ valueName }
<input class="input full-width" minlength="1" maxlength="32" bind:value={ userInput } />
</label>
<div class="modal-footer">
<button class="button modal-secondary-action" on:click="{ close }">Cancel</button>
<button class="button button-accent modal-primary-action" on:click="{ save }" disabled="{ !buttonsEnabled }">Submit</button>
</div>
</div>
</div>

View file

@ -4,6 +4,8 @@
import { overlayStore, userInfoStore, smallViewport, theme, doAnimations } from "../../stores"; import { overlayStore, userInfoStore, smallViewport, theme, doAnimations } from "../../stores";
import { logOut } from "../../auth"; import { logOut } from "../../auth";
import { maybeFade, maybeFly } from "../../animations"; import { maybeFade, maybeFly } from "../../animations";
import request from "../../request";
import { apiRoute } from "../../storage";
const close = () => overlayStore.close("settings"); const close = () => overlayStore.close("settings");
@ -14,6 +16,23 @@
message: "Logged out" message: "Logged out"
}); });
}; };
const doSuperuserPrompt = () => {
overlayStore.open("prompt", {
heading: "Become Superuser",
valueName: "Superuser Key",
async onSubmit(value) {
const { ok } = await request("POST", apiRoute("users/self/promote"), true, {
key: value
});
if (ok) {
overlayStore.open("toast", { message: "You have been promoted to superuser" });
} else {
overlayStore.open("toast", { message: "Failed to promote to superuser" });
}
}
});
};
</script> </script>
<style> <style>
@ -53,7 +72,7 @@
min-height: 420px; min-height: 420px;
} }
.inner-logout-button { .account-buttons {
margin-left: auto; margin-left: auto;
} }
</style> </style>
@ -64,7 +83,10 @@
<div class="settings-card full-width"> <div class="settings-card full-width">
<AtSignIcon /> <AtSignIcon />
<span class="h5 top-bar-heading">{ $userInfoStore ? $userInfoStore.username : "" }</span> <span class="h5 top-bar-heading">{ $userInfoStore ? $userInfoStore.username : "" }</span>
<button class="button button-red inner-logout-button" on:click="{ doLogout }">Log Out</button> <div class="account-buttons">
<button class="button button-red" on:click="{ doSuperuserPrompt }">Become Superuser</button>
<button class="button button-red" on:click="{ doLogout }">Log Out</button>
</div>
</div> </div>
<div class="separator" /> <div class="separator" />

View file

@ -303,6 +303,7 @@ class OverlayStore extends Store {
createAccount: null, createAccount: null,
editMessage: null, editMessage: null,
settings: null, settings: null,
prompt: null,
}, "OverlayStore"); }, "OverlayStore");
} }

View file

@ -5,7 +5,8 @@ export default async function databaseInit() {
CREATE TABLE IF NOT EXISTS users( CREATE TABLE IF NOT EXISTS users(
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
username VARCHAR(32) UNIQUE NOT NULL, username VARCHAR(32) UNIQUE NOT NULL,
password TEXT password TEXT,
is_superuser BOOLEAN
); );
CREATE TABLE IF NOT EXISTS channels( CREATE TABLE IF NOT EXISTS channels(

View file

@ -4,6 +4,7 @@ export const errors = {
BAD_AUTH: { code: 6003, message: "Bad authentication" }, BAD_AUTH: { code: 6003, message: "Bad authentication" },
NOT_FOUND: { code: 6004, message: "Not found" }, NOT_FOUND: { code: 6004, message: "Not found" },
FORBIDDEN_DUE_TO_MISSING_PERMISSIONS: { code: 6005, message: "Forbidden due to missing permission(s)" }, FORBIDDEN_DUE_TO_MISSING_PERMISSIONS: { code: 6005, message: "Forbidden due to missing permission(s)" },
BAD_SUPERUSER_KEY: { code: 6006, message: "Bad superuser key" },
GOT_NO_DATABASE_DATA: { code: 7001, message: "Unexpectedly got no data from database" }, GOT_NO_DATABASE_DATA: { code: 7001, message: "Unexpectedly got no data from database" },
FEATURE_DISABLED: { code: 7002, message: "This feature is disabled" } FEATURE_DISABLED: { code: 7002, message: "This feature is disabled" }
}; };

View file

@ -6,6 +6,7 @@ import { getMessageById, getMessagesByChannelFirstPage, getMessagesByChannelPage
import { errors } from "../../../errors"; import { errors } from "../../../errors";
import { dispatch, dispatchChannelSubscribe } from "../../../gateway"; import { dispatch, dispatchChannelSubscribe } from "../../../gateway";
import { GatewayPayloadType } from "../../../gateway/gatewaypayloadtype"; import { GatewayPayloadType } from "../../../gateway/gatewaypayloadtype";
import serverConfig from "../../../serverconfig";
const router = express.Router(); const router = express.Router();
@ -19,6 +20,10 @@ router.post(
return res.status(400).json({ ...errors.INVALID_DATA, errors: validationErrors.array() }); return res.status(400).json({ ...errors.INVALID_DATA, errors: validationErrors.array() });
} }
if (serverConfig.superuserRequirement.createChannel && !req.user.is_superuser) {
return res.status(403).json({ ...errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS });
}
const { name } = req.body; const { name } = req.body;
const result = await query("INSERT INTO channels(name, owner_id) VALUES ($1, $2) RETURNING id, name, owner_id", [name, req.user.id]); const result = await query("INSERT INTO channels(name, owner_id) VALUES ($1, $2) RETURNING id, name, owner_id", [name, req.user.id]);
if (result.rowCount < 1) { if (result.rowCount < 1) {
@ -60,7 +65,7 @@ router.put(
...errors.NOT_FOUND ...errors.NOT_FOUND
}); });
} }
if (permissionCheckResult.rows[0].owner_id !== req.user.id) { if (permissionCheckResult.rows[0].owner_id !== req.user.id && !req.user.is_superuser) {
return res.status(403).json({ return res.status(403).json({
...errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS ...errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS
}); });
@ -106,7 +111,7 @@ router.delete(
...errors.NOT_FOUND ...errors.NOT_FOUND
}); });
} }
if (permissionCheckResult.rows[0].owner_id !== req.user.id) { if (permissionCheckResult.rows[0].owner_id !== req.user.id && !req.user.is_superuser) {
return res.status(403).json({ return res.status(403).json({
...errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS ...errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS
}); });

View file

@ -27,7 +27,7 @@ router.delete(
...errors.NOT_FOUND ...errors.NOT_FOUND
}); });
} }
if (permissionCheckResult.rows[0].author_id !== req.user.id) { if (permissionCheckResult.rows[0].author_id !== req.user.id && !req.user.is_superuser) {
return res.status(403).json({ return res.status(403).json({
...errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS ...errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS
}); });
@ -72,7 +72,7 @@ router.put(
...errors.NOT_FOUND ...errors.NOT_FOUND
}); });
} }
if (permissionCheckResult.rows[0].author_id !== req.user.id) { if (permissionCheckResult.rows[0].author_id !== req.user.id && !req.user.is_superuser) {
return res.status(403).json({ return res.status(403).json({
...errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS ...errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS
}); });

View file

@ -7,6 +7,8 @@ import { authenticateRoute, signToken } from "../../../auth";
const router = express.Router(); const router = express.Router();
const superuserKey = process.env.SUPERUSER_KEY || "";
router.post( router.post(
"/register", "/register",
body("username").isLength({ min: 3, max: 32 }).isAlphanumeric("en-US", { ignore: " _-" }), body("username").isLength({ min: 3, max: 32 }).isAlphanumeric("en-US", { ignore: " _-" }),
@ -32,7 +34,7 @@ router.post(
} }
const hashedPassword = await hash(password, 10); const hashedPassword = await hash(password, 10);
const insertedUser = await query("INSERT INTO users(username, password) VALUES ($1, $2) RETURNING id, username", [username, hashedPassword]); const insertedUser = await query("INSERT INTO users(username, password, is_superuser) VALUES ($1, $2, $3) RETURNING id, username, is_superuser", [username, hashedPassword, false]);
if (insertedUser.rowCount < 1) { if (insertedUser.rowCount < 1) {
return res.status(500).json({ return res.status(500).json({
...errors.GOT_NO_DATABASE_DATA ...errors.GOT_NO_DATABASE_DATA
@ -78,4 +80,31 @@ router.get(
} }
); );
router.post(
"/self/promote",
authenticateRoute(),
body("key").isLength({ min: 1, max: 3000 }),
async (req, res) => {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.status(403).json({ ...errors.BAD_SUPERUSER_KEY });
}
const { key } = req.body;
if (superuserKey && superuserKey.length >= 1 && key === superuserKey && req.user) {
const updateUserResult = await query("UPDATE users SET is_superuser = true WHERE id = $1", [req.user.id]);
if (updateUserResult.rowCount < 1) {
return res.status(500).json({
...errors.GOT_NO_DATABASE_DATA
});
}
return res.status(200).json({});
}
return res.status(403).json({ ...errors.BAD_SUPERUSER_KEY });
}
);
export default router; export default router;

5
src/serverconfig.ts Normal file
View file

@ -0,0 +1,5 @@
export default {
superuserRequirement: {
createChannel: true
},
};

3
src/types/user.d.ts vendored
View file

@ -1,5 +1,6 @@
interface User { interface User {
password?: string, password?: string,
username: string, username: string,
id: number id: number,
is_superuser: boolean
} }