diff --git a/src/comp/actionbar.svelte b/src/comp/actionbar.svelte index b63a90e..c80f942 100644 --- a/src/comp/actionbar.svelte +++ b/src/comp/actionbar.svelte @@ -20,6 +20,6 @@
- +
diff --git a/src/comp/cardicon.svelte b/src/comp/cardicon.svelte index 363da17..1b878db 100644 --- a/src/comp/cardicon.svelte +++ b/src/comp/cardicon.svelte @@ -20,7 +20,7 @@ /** * @type {string | undefined} */ - export let iconSrc; + export let iconSrc = undefined; /** * @type {number} diff --git a/src/comp/iconvalue.svelte b/src/comp/iconvalue.svelte index e194fdb..ce8b445 100644 --- a/src/comp/iconvalue.svelte +++ b/src/comp/iconvalue.svelte @@ -15,6 +15,21 @@ * @type {number} */ export let iconSize = 16; + + /** + * @type {string} + */ + export let color = "white"; + + /** + * @type {string} + */ + export let iconColor = "white"; + + /** + * @type {string | undefined} + */ + export let textStyle = undefined; -
- - +
+ +
diff --git a/src/comp/page/posts/postcard.svelte b/src/comp/page/posts/postcard.svelte index ac69390..fdbc78a 100644 --- a/src/comp/page/posts/postcard.svelte +++ b/src/comp/page/posts/postcard.svelte @@ -17,6 +17,8 @@ export let opened = false; const dispatch = createEventDispatcher(); + +console.log(post);
- {rating.likes} - {rating.dislikes} + {#if ownVote == 1} +
+ +
+ {:else} +
+ +
+ {/if} + + {#if ownVote == -1} +
+ +
+ {:else} +
+ +
+ {/if}
diff --git a/src/comp/topbar.svelte b/src/comp/topbar.svelte new file mode 100644 index 0000000..4758856 --- /dev/null +++ b/src/comp/topbar.svelte @@ -0,0 +1,91 @@ + + + + +
+ + + +
diff --git a/src/lib/memory/session.js b/src/lib/memory/session.js new file mode 100644 index 0000000..77761b5 --- /dev/null +++ b/src/lib/memory/session.js @@ -0,0 +1,17 @@ +import { writable } from "svelte/store"; + +/** @type {import("svelte/store").Writable} */ +const activeSession = writable(null); + +export default activeSession; + +/** + * @param {import("$types/base").User} user + */ +export function logInAs(user) { + activeSession.set(user); +} + +export function logOut() { + activeSession.set(null); +} diff --git a/src/lib/server/db/comment.js b/src/lib/server/db/comment.js index ca09627..3072fa7 100644 --- a/src/lib/server/db/comment.js +++ b/src/lib/server/db/comment.js @@ -1,5 +1,6 @@ import { sql } from '$lib/db.server'; -import { getUsersCachedByRef } from './user'; +import { getUsersCachedByRef, sqlUserFromToken } from './user'; +import { sqlOwnVote } from './vote'; /** * @typedef {import('$types/base').Comment} Comment @@ -22,17 +23,21 @@ function parseCommentFromRow(author, row) { rating: { likes: BigInt(row['likes']), dislikes: BigInt(row['dislikes']), - } + }, + ownVote: row['own_vote'], }; } /** + * @param {string | null} token * @param {number} post_id * @returns {Promise} */ -export async function getCommentsForPost(post_id) { +export async function getCommentsForPost(token, post_id) { + const ownVote = token ? sql`, (${ sqlOwnVote( sql`( ${sqlUserFromToken(token)} )` ) } AND message_id = id) as "own_vote"` : sql``; + const query = sql` - SELECT id, author_id, latest_content, edit_count, parent_comment_id, created_date, likes, dislikes + SELECT id, author_id, latest_content, edit_count, parent_comment_id, created_date, likes, dislikes ${ ownVote } FROM doki8902.message_comment WHERE post_id = ${ post_id };`; diff --git a/src/lib/server/db/post.js b/src/lib/server/db/post.js index 126f19c..063de25 100644 --- a/src/lib/server/db/post.js +++ b/src/lib/server/db/post.js @@ -1,7 +1,8 @@ import { sql } from '$lib/db.server'; import { getCategoriesCachedByRef, getCategoryCached } from './category'; import { isPostgresError } from './root'; -import { getUser, getUsersCachedByRef, sqlUserFromToken } from './user'; +import { getUser, getUsersCachedByRef, sqlElevatedUserFromToken, sqlUserFromToken } from './user'; +import { sqlOwnVote } from './vote'; /** * @typedef {import('$types/base').Post} Post @@ -46,6 +47,7 @@ function parsePostFromRow(author, category, row, withMetrics = false) { dislikes: BigInt(row['dislikes']), }, metrics: withMetrics ? parsePostMetricsFromRow(row) : null, + ownVote: row['own_vote'], }; } @@ -73,6 +75,7 @@ export async function getPostCount(opts = {}) { } /** + * @param {string | null} token * @param {{ * category?: import('$types/base').Category | undefined, * limit?: number, @@ -81,7 +84,7 @@ export async function getPostCount(opts = {}) { * }} opts * @returns {Promise} */ -export async function getPosts(opts = {}) { +export async function getPosts(token = null, opts = {}) { const { category = undefined, limit = 10, @@ -92,8 +95,10 @@ export async function getPosts(opts = {}) { const filter = category ? sql`AND category_id = ${ category.id }` : sql``; const metrics = withMetrics ? sql`, comment_count, user_count, latest_activity, engagement, age, relevancy` : sql``; + const ownVote = token ? sql`, (${ sqlOwnVote( sql`( ${sqlUserFromToken(token)} )` ) } AND message_id = id) as "own_vote"` : sql``; + const query = sql` - SELECT id, author_id, name, category_id, latest_content, reviewed, edit_count, created_date, likes, dislikes ${ metrics } + SELECT id, author_id, name, category_id, latest_content, reviewed, edit_count, created_date, likes, dislikes ${ metrics } ${ ownVote } FROM doki8902.message_post WHERE reviewed ${ filter } FETCH FIRST ${ limit } ROWS ONLY @@ -131,10 +136,12 @@ export async function getPost(post_id, token = null, opts = {}) { const metrics = withMetrics ? sql`, comment_count, user_count, latest_activity, engagement, age, relevancy` : sql``; + const ownVote = token ? sql`, (${ sqlOwnVote( sql`( ${sqlUserFromToken(token)} )` ) } AND message_id = id) as "own_vote"` : sql``; + const allowOwn = token ? sql`OR author_id = (${ sqlUserFromToken(token) })` : sql``; const query = sql` - SELECT id, author_id, name, category_id, latest_content, reviewed, edit_count, created_date, likes, dislikes ${ metrics } + SELECT id, author_id, name, category_id, latest_content, reviewed, edit_count, created_date, likes, dislikes ${ metrics } ${ ownVote } FROM doki8902.message_post WHERE id = ${ post_id } AND (reviewed ${ allowOwn });`; @@ -189,21 +196,45 @@ export async function getPost(post_id, token = null, opts = {}) { * @param {import('$types/base').Category} category * @param {string} name * @param {string} content + * @param {{ + * auto_approve?: boolean + * }} opts * @returns {Promise} */ -export async function createPost(token, category, name, content) { - const insert = sql` - INSERT INTO doki8902.message_post (author_id, category_id, name, latest_content) - VALUES ( - (${ sqlUserFromToken(token) }), - ${ category.id }, ${ name }, ${ content } - ) - RETURNING id;`; +export async function createPost(token, category, name, content, opts = {}) { + const { + auto_approve = false + } = opts; + + const transaction = sql.begin(async sql => { + if (auto_approve) { + const result = await sql` + INSERT INTO doki8902.message_post (author_id, category_id, name, latest_content, reviewed) + VALUES ( + (${ sqlElevatedUserFromToken(token) }), + ${ category.id }, ${ name }, ${ content }, TRUE + ) + RETURNING id;`; + + await sql` + REFRESH MATERIALIZED VIEW doki8902.message_content_latest;`; + + return result; + } + + return await sql` + INSERT INTO doki8902.message_post (author_id, category_id, name, latest_content) + VALUES ( + (${ sqlUserFromToken(token) }), + ${ category.id }, ${ name }, ${ content } + ) + RETURNING id;` + }) let result; try { - result = await insert; + result = await transaction; } catch (e) { const pgerr = isPostgresError(e); diff --git a/src/lib/server/db/user.js b/src/lib/server/db/user.js index dffb877..f561b37 100644 --- a/src/lib/server/db/user.js +++ b/src/lib/server/db/user.js @@ -23,7 +23,9 @@ function parseUserFromRow(row) { return { id: row['id'], name: row['username'], - joinDate: row['join_time'] + about: row['about'], + joinDate: row['join_time'], + access: row['access'], }; } @@ -38,7 +40,23 @@ const updateUserCache = cacheUpdater(cache); * @returns {import('postgres').PendingQuery} */ export function sqlUserFromToken(token) { - return sql`SELECT user_id FROM doki8902.user_session WHERE token = ${ token }`; + return sql` + SELECT user_id + FROM doki8902.user_session + WHERE token = ${ token }`; +} + +/** + * @param {string} token + * @returns {import('postgres').PendingQuery} + */ +export function sqlElevatedUserFromToken(token) { + return sql` + SELECT user_id + FROM doki8902.user_session + JOIN doki8902.user ON user_id = id + WHERE token = ${ token } + AND access = 'elevated'`; } /** @@ -57,7 +75,7 @@ export async function getUsers(user_ids) { if (user_ids.length == 0) return new Map(); const query = sql` - SELECT id, username, join_time + SELECT id, username, about, join_time, access FROM doki8902.user WHERE id IN ${ sql(user_ids) };`; @@ -84,7 +102,9 @@ export async function getUser(user_id) { return users.get(user_id) || { error: true, - msg: `Could not find user of ID ${user_id}` + title: 'User not found', + msg: `Could not find user of ID ${user_id}`, + expected: true, }; } @@ -152,6 +172,32 @@ export async function createUser(username, password) { }; } +/** + * @param {string} token + * @returns {Promise} + */ +export async function deleteUser(token) { + const del = sql` + DELETE FROM doki8902.user + WHERE id = (${ sqlUserFromToken(token) });`; + + try { + await del; + } catch (e) { + console.log(e); + return { + error: true, + title: 'Fail to process', + msg: 'Unknown error (notify dev)', + expected: false, + }; + } + + return { + success: true, + }; +} + /** * @param {string} username * @param {string} password @@ -200,6 +246,32 @@ export async function createUserSession(username, password) { }; } +/** + * @param {string} token + * @returns {Promise} + */ +export async function removeUserSession(token) { + const del = sql` + DELETE FROM doki8902.user_session + WHERE token = ${ token };`; + + try { + await del; + } catch (e) { + console.log(e); + return { + error: true, + title: 'Fail to process', + msg: 'Unknown error (notify dev)', + expected: false, + }; + } + + return { + success: true, + }; +} + /** * @param {string} token * @returns {Promise} @@ -230,9 +302,10 @@ export async function getUserIDOfSession(token) { */ export async function getUserOfSession(token) { const query = sql` - SELECT user_id + SELECT id, username, about, join_time, access FROM doki8902.user_session - WHERE token = ${ token }`; + JOIN doki8902.user ON user_id = id + WHERE token = ${ token };`; const result = await query; @@ -245,5 +318,100 @@ export async function getUserOfSession(token) { }; } - return result[0]['user_id']; + return parseUserFromRow(result[0]); +} + +/** + * @param {string} token + * @param {string} about + * @returns {Promise} + */ +export async function updateUserAbout(token, about) { + const update = sql` + UPDATE doki8902.user + SET about = ${ about } + WHERE id = (${ sqlUserFromToken(token) });`; + + try { + await update; + } catch (e) { + console.log(e); + return { + error: true, + title: 'Fail to process', + msg: 'Unknown error (notify dev)', + expected: false, + }; + } + + return { + success: true, + }; +} + +/** + * @param {string} token + * @param {string} currentPassword + * @param {string} newPassword + * @returns {Promise} + */ +export async function changeUserPassword(token, currentPassword, newPassword) { + const select = sql` + SELECT password, id + FROM doki8902.user_session + JOIN doki8902.user ON id = user_id + WHERE token = ${ token };`; + + const result = await select; + + if (result.length == 0) { + return { + error: true, + title: 'Invalid data', + msg: 'Username or password is incorrect', + expected: true, + }; + } + + const hashedPassword = result[0]['password']; + + const isMatch = await verify(hashedPassword, currentPassword); + + if (!isMatch) { + return { + error: true, + title: 'Invalid data', + msg: 'Username or password is incorrect', + expected: true, + }; + } + + const newHashedPassword = await hash(newPassword, { + type: argon2id, + memoryCost: 2 ** 16, + timeCost: 4, + parallelism: 1, + hashLength: 64, + }); + + const update = sql` + UPDATE doki8902.user + SET password = ${ newHashedPassword } + WHERE id = (${ result[0]['id'] });`; + + try { + await update; + } catch (e) { + console.log(e); + return { + error: true, + title: 'Fail to process', + msg: 'Unknown error (notify dev)', + expected: false, + }; + } + + return { + success: true, + }; } diff --git a/src/lib/server/db/vote.js b/src/lib/server/db/vote.js new file mode 100644 index 0000000..c4f341e --- /dev/null +++ b/src/lib/server/db/vote.js @@ -0,0 +1,107 @@ +import { sql } from '$lib/db.server'; +import { sqlUserFromToken } from './user'; + +/** + * @param {number | import('postgres').PendingQuery} user_id + * @returns {import('postgres').PendingQuery} + */ +export function sqlOwnVote(user_id) { + return sql` + SELECT vote + FROM doki8902.message_vote + WHERE user_id = ${ user_id }`; +} + +/** + * @param {string} token + * @param {number} message_id + * @returns {Promise} + */ +export async function getVote(token, message_id) { + const insert = await sql` + SELECT vote + FROM doki8902.message_vote + WHERE user_id = ${ sqlUserFromToken(token) } + AND message_id = ${ message_id };`; + + try { + const [result] = await insert; + + return { + success: true, + result: result, + } + } catch (e) { + console.log(e); + return { + error: true, + title: 'Fail to process', + msg: 'Unknown error (notify dev)', + expected: false, + }; + } +} + +/** + * @param {string} token + * @param {number} message_id + * @param {number} value + * @returns {Promise} + */ +export async function createVote(token, message_id, value) { + const insert = await sql` + INSERT INTO doki8902.message_vote (message_id, user_id, vote) + VALUES ( + ${ message_id }, + (${ sqlUserFromToken(token) }), + ${ value } + ) + ON CONFLICT (message_id, user_id) DO UPDATE + SET vote = ${ value };`; + + try { + const result = await insert; + + console.log(result); + } catch (e) { + console.log(e); + return { + error: true, + title: 'Fail to process', + msg: 'Unknown error (notify dev)', + expected: false, + }; + } + + return { + success: true, + }; +} + +/** + * @param {string} token + * @param {number} message_id + * @returns {Promise} + */ +export async function removeVote(token, message_id) { + const del = await sql` + DELETE FROM doki8902.message_vote + WHERE user_id = (${ sqlUserFromToken(token) }) + AND message_id = ${ message_id };`; + + try { + await del; + } catch (e) { + console.log(e); + return { + error: true, + title: 'Fail to process', + msg: 'Unknown error (notify dev)', + expected: false, + }; + } + + return { + success: true, + }; +} diff --git a/src/routes/(app)/+layout.server.js b/src/routes/(app)/+layout.server.js index 9758501..b2fc9c5 100644 --- a/src/routes/(app)/+layout.server.js +++ b/src/routes/(app)/+layout.server.js @@ -1,10 +1,16 @@ import { getCategories } from "$lib/server/db/category"; +import { getUserOfSession } from "$lib/server/db/user"; /** @type {import("@sveltejs/kit").ServerLoad} */ -export async function load() { +export async function load({ cookies }) { + const token = cookies.get('token')?.toString() ?? null; + const categories = await getCategories(); + const loggedInUser = token ? await getUserOfSession(token) : null; + return { - categories: categories + categories: categories, + loggedInUser: loggedInUser }; } diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 7709bc8..2bbef7d 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -1,18 +1,36 @@
- -
- -
+ + +
+ +
+ +
+
diff --git a/src/routes/(app)/compose/+page.server.js b/src/routes/(app)/compose/+page.server.js index 32ef0a6..e95f841 100644 --- a/src/routes/(app)/compose/+page.server.js +++ b/src/routes/(app)/compose/+page.server.js @@ -33,6 +33,7 @@ async function POST({ request, cookies }) { const categoryId = parseIntNull(data.get('category')?.toString()); const name = data.get('name')?.toString(); const content = data.get('content')?.toString(); + const autoapprove = data.get('autoapprove')?.toString() === 'on'; if (!categoryId) { return fail(400, { @@ -60,7 +61,9 @@ async function POST({ request, cookies }) { }); } - const result = await createPost(userToken, category, name, content); + const result = await createPost(userToken, category, name, content, { + auto_approve: autoapprove + }); runIfSuccess(result, (success) => { redirect(303, `/posts/${success.result}`); diff --git a/src/routes/(app)/compose/+page.svelte b/src/routes/(app)/compose/+page.svelte index b35ad62..3116212 100644 --- a/src/routes/(app)/compose/+page.svelte +++ b/src/routes/(app)/compose/+page.svelte @@ -1,6 +1,7 @@ -

{data.user.name}

-

{data.user.joinDate}

+ + + + + + +{#if showAboutModal} +
+
+
+ + +
+ + +
+
+
+{/if} + +{#if showPasswordModal} +
+
+
{showPasswordModal = false;}}> + + + + +
+ + +
+
+
+{/if} + +
+ +

@ {user.name}

+ {user.about} +

Joined {moment(user.joinDate).fromNow()}

+ + {#if user.id == $activeSession?.id} + +
+
+

Self-actions

+ These actions are only available to you as the account owner +
+ +
+
+ Change my about me +
+ +
+
+ +
+ Change my current password +
+ +
+
+ +
+ Log out of the current session +
+ +
+
+ +
+ Permanently delete the account +
+ +
+
+
+
+
+ {/if} +
diff --git a/src/routes/(app)/vote/+page.server.js b/src/routes/(app)/vote/+page.server.js new file mode 100644 index 0000000..c9bbe60 --- /dev/null +++ b/src/routes/(app)/vote/+page.server.js @@ -0,0 +1,79 @@ +import { createUserSession } from "$lib/server/db/user"; +import { createVote, removeVote } from "$lib/server/db/vote"; +import { runIfError } from "$lib/status"; +import { errorToFail } from "$lib/status.server"; +import { parseIntNull } from "$lib/util"; +import { fail, redirect } from "@sveltejs/kit"; + +/** @type {import("@sveltejs/kit").Action} */ +async function createVoteAction({ url, cookies }) { + const userToken = cookies.get('token'); + + if (!userToken) { + return fail(401, { + error: true, + title: 'Invalid session', + msg: 'Need to be logged in to perform this operation', + }); + } + + const messageID = parseIntNull(url.searchParams.get('messageid')); + const value = parseIntNull(url.searchParams.get('value')); + + if (value != 1 && value != -1) { + return fail(400, { + error: true, + title: 'Bad data', + msg: 'Value can only be 1 or -1', + }); + } + + if (!messageID) { + return fail(400, { + error: true, + title: 'Bad data', + msg: 'MessageID cannot be empty', + }); + } + + const result = await createVote(userToken, messageID, value); + + return runIfError(result, (error) => { + return errorToFail(error); + }); +} + +/** @type {import("@sveltejs/kit").Action} */ +async function removeVoteAction({ url, cookies }) { + const userToken = cookies.get('token'); + + if (!userToken) { + return fail(401, { + error: true, + title: 'Invalid session', + msg: 'Need to be logged in to perform this operation', + }); + } + + const messageID = parseIntNull(url.searchParams.get('messageid')); + + if (!messageID) { + return fail(400, { + error: true, + title: 'Bad data', + msg: 'MessageID cannot be empty', + }); + } + + const result = await removeVote(userToken, messageID); + + return runIfError(result, (error) => { + return errorToFail(error); + }); +} + +/** @type {import("@sveltejs/kit").Actions} */ +export let actions = { + create: createVoteAction, + remove: removeVoteAction, +}; diff --git a/src/routes/(entry)/logout/+page.server.js b/src/routes/(entry)/logout/+page.server.js new file mode 100644 index 0000000..fa55af0 --- /dev/null +++ b/src/routes/(entry)/logout/+page.server.js @@ -0,0 +1,16 @@ +import { createUserSession, removeUserSession } from "$lib/server/db/user"; +import { runIfError } from "$lib/status"; +import { errorToFail } from "$lib/status.server"; +import { fail, redirect } from "@sveltejs/kit"; + +/** @type {import("@sveltejs/kit").ServerLoad} */ +export async function load({ cookies }) { + const userToken = cookies.get('token'); + + if (userToken) { + await removeUserSession(userToken); + cookies.delete('token', { path: '/' }) + } + + redirect(302, '/'); +} diff --git a/src/routes/api/user/+server.js b/src/routes/api/user/+server.js new file mode 100644 index 0000000..ee664ca --- /dev/null +++ b/src/routes/api/user/+server.js @@ -0,0 +1,19 @@ +import { jsonSerialize } from "$lib/serialize/base"; +import { getUser } from "$lib/server/db/user"; +import { parseIntNull } from "$lib/util"; +import { error } from "@sveltejs/kit"; + +/** @type {import("@sveltejs/kit").Action} */ +export async function GET({ url }) { + const user_id = parseIntNull(url.searchParams.get('id')); + + if (user_id === null) { + error(404, `No User of ID ${url.searchParams.get('id')}`); + } + + const user = await getUser(user_id); + + return new Response(jsonSerialize({ + user: user, + })); +} diff --git a/src/types/base.ts b/src/types/base.ts index 3be6757..c4cc550 100644 --- a/src/types/base.ts +++ b/src/types/base.ts @@ -3,7 +3,9 @@ export type Result = Map; export type User = { id: number, name: string, + about: string, joinDate: Date, + access: 'normal' | 'elevated' }; export type Rating = { @@ -37,6 +39,7 @@ export type Post = { postDate: Date, rating: Rating, metrics: PostMetrics | null, + ownVote: number | null, }; export type Comment = { @@ -48,6 +51,7 @@ export type Comment = { commentDate: Date, rating: Rating, parentCommentId: number, + ownVote: number | null, }; export type CommentTreeNode = { diff --git a/static/css/base.scss b/static/css/base.scss index e075407..9685b4e 100644 --- a/static/css/base.scss +++ b/static/css/base.scss @@ -2,6 +2,7 @@ font-size: 16px; --background: #111118; + --background-black: #060608c0; --accent-gray: #b182a3; diff --git a/static/css/form.scss b/static/css/form.scss index 0ecbc91..26485bf 100644 --- a/static/css/form.scss +++ b/static/css/form.scss @@ -5,7 +5,8 @@ form { justify-content: center; gap: 32px; - button[type=submit] { + button[type=submit], + .formButton { background: var(--accent-dim); border-radius: 4px; padding: 0.3em 0.8em; diff --git a/static/icon/heart-fill.svg b/static/icon/heart-fill.svg new file mode 100644 index 0000000..b5d3657 --- /dev/null +++ b/static/icon/heart-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/icon/thumb-down-fill.svg b/static/icon/thumb-down-fill.svg new file mode 100644 index 0000000..c0d5496 --- /dev/null +++ b/static/icon/thumb-down-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file