diff --git a/client/components/Problem.jsx b/client/components/Problem.jsx index e3dbefc..cfcad39 100644 --- a/client/components/Problem.jsx +++ b/client/components/Problem.jsx @@ -1,12 +1,13 @@ import { Markdown } from './Markdown.jsx' -export const Problem = ({ id, content }) => { +export const Problem = ({ id, content, createdBy }) => { return (
+ {createdBy &&
Creato da @{createdBy}
}
diff --git a/client/components/Solution.jsx b/client/components/Solution.jsx new file mode 100644 index 0000000..4bc888b --- /dev/null +++ b/client/components/Solution.jsx @@ -0,0 +1,14 @@ +import { Markdown } from './Markdown.jsx' + +export const Solution = ({ userId, content }) => { + return ( +
+
+
@{userId}
+
+
+ +
+
+ ) +} diff --git a/client/entry-client.jsx b/client/entry-client.jsx index bcbb351..f81a851 100644 --- a/client/entry-client.jsx +++ b/client/entry-client.jsx @@ -2,3 +2,11 @@ import { hydrate } from 'preact' import { App } from './App.jsx' hydrate(, document.body) + +window.fetchServer = url => { + fetch(url, { credentials: 'include' }) + .then(res => res.ok && res.json()) + .then(data => { + console.log(data) + }) +} diff --git a/client/entry-server.jsx b/client/entry-server.jsx index a8d3392..43f9af7 100644 --- a/client/entry-server.jsx +++ b/client/entry-server.jsx @@ -1,6 +1,15 @@ import renderToString from 'preact-render-to-string' import { App } from './App.jsx' +import { MetadataContext } from './hooks.jsx' export function render(url) { - return renderToString() + const metadata = {} + + const html = renderToString( + + + + ) + + return { html, metadata } } diff --git a/client/hooks.jsx b/client/hooks.jsx index 73230a9..e387ce0 100644 --- a/client/hooks.jsx +++ b/client/hooks.jsx @@ -1,6 +1,10 @@ import { useEffect, useState } from 'preact/hooks' -export const useUser = () => { +import { createContext } from 'preact' + +export const MetadataContext = createContext({}) + +export const useCurrentUser = onLoaded => { const [user, setUser] = useState(null) const logout = () => { @@ -11,12 +15,48 @@ export const useUser = () => { const res = await fetch(`/api/current-user`, { credentials: 'include', }) + const user = await res.json() + setUser(user) - if (res.ok) { - const user = await res.json() - setUser(user) - } + console.log(`Current user is`, user) + + onLoaded?.(user) }, []) + useEffect(() => { + console.log(user) + }, [user]) + return [user, logout] } + +export const useReadResource = (url, initialValue) => { + const [value, setValue] = useState(initialValue) + + function refresh() { + const controller = new AbortController() + + fetch(url, { signal: controller.signal }) + .then(res => { + if (res.ok) { + return res.json() + } else { + return initialValue + } + }) + .then(newValue => { + setValue(newValue) + }) + + return controller + } + + useEffect(() => { + const controller = refresh() + return () => { + controller.abort() + } + }, []) + + return [value, refresh] +} diff --git a/client/pages/Admin.jsx b/client/pages/Admin.jsx new file mode 100644 index 0000000..a3024db --- /dev/null +++ b/client/pages/Admin.jsx @@ -0,0 +1,26 @@ +import { route } from 'preact-router' +import { useEffect } from 'preact/hooks' +import { useCurrentUser } from '../hooks.jsx' + +export const Admin = ({}) => { + const [user] = useCurrentUser(user => { + if (!user || user.role !== 'admin') { + route('/', true) + } + }) + + return ( +
+ +
+ {user ? ( + <> + Logged in as {user.role} @{user.username} + + ) : ( + Login + )} +
+
+ ) +} diff --git a/client/pages/Home.jsx b/client/pages/Home.jsx index 4a70b6a..56737e0 100644 --- a/client/pages/Home.jsx +++ b/client/pages/Home.jsx @@ -1,12 +1,11 @@ import { route } from 'preact-router' +import { useEffect, useState } from 'preact/hooks' import { Problem } from '../components/Problem.jsx' -import { useUser } from '../hooks.jsx' +import { useReadResource, useCurrentUser } from '../hooks.jsx' export const HomePage = () => { - console.log('rendering homepage') - - const [user, logout] = useUser() + const [user, logout] = useCurrentUser() const handleLogout = async () => { await fetch(`/api/logout`, { @@ -16,13 +15,7 @@ export const HomePage = () => { logout() } - const problems = Array.from({ length: 20 }, (_, i) => ({ - id: i + 1, - content: - `Lorem ipsum dolor sit amet, consectetur adipisicing elit. Architecto porro commodi cumque ratione sequi reiciendis corrupti a eius praesentium.\n`.repeat( - ((i + 2) % 4) + 1 - ), - })) + const [problems] = useReadResource('/api/problems', []) return (
@@ -30,7 +23,7 @@ export const HomePage = () => {
{user ? ( <> - Logged in as {user.username} ( + Logged in as {user.role} @{user.username} ( Logout diff --git a/client/pages/Login.jsx b/client/pages/Login.jsx index 87132b7..5845c16 100644 --- a/client/pages/Login.jsx +++ b/client/pages/Login.jsx @@ -31,10 +31,11 @@ export const LoginPage = () => { type="text" value={username} onInput={e => setUsername(e.target.value)} + onKeyDown={e => e.key === 'Enter' && login()} />
- +
diff --git a/client/pages/Problem.jsx b/client/pages/Problem.jsx index 3ce1ea9..534d2db 100644 --- a/client/pages/Problem.jsx +++ b/client/pages/Problem.jsx @@ -1,23 +1,36 @@ -import { useEffect, useRef, useState } from 'preact/hooks' +import { useContext, useEffect, useRef, useState } from 'preact/hooks' import { Markdown } from '../components/Markdown.jsx' import { Problem } from '../components/Problem.jsx' +import { Solution } from '../components/Solution.jsx' +import { MetadataContext, useCurrentUser, useReadResource } from '../hooks.jsx' export const ProblemPage = ({ id }) => { + const metadata = useContext(MetadataContext) + metadata.title = `Problem ${id}` + + const [user] = useCurrentUser() + const [source, setSource] = useState('') const editorRef = useRef() + const [{ content }] = useReadResource(`/api/problem/${id}`, { content: '' }) + + const [solutions] = useReadResource(`/api/solutions?problem=${id}`, []) + const sendSolution = async () => { - const res = await fetch(`/api/problem/${id}/new-solution`, { + const res = await fetch(`/api/solution`, { + method: 'POST', + credentials: 'include', headers: { 'Content-Type': 'application/json', }, - credentials: 'include', body: JSON.stringify({ - source, + problemId: id, + content: source, }), }) - console.log(await res.json()) + location.reload() } useEffect(() => { @@ -36,39 +49,57 @@ export const ProblemPage = ({ id }) => { return (
-
Testo del problema
- -
Invia una soluzione al problema
-
-
-

Editor

- + {user && ( +
+ Logged in as {user.role} @{user.username}
-
-

Preview

-
- {source.trim().length ? ( - - ) : ( -
Scrivi una nuova soluzione...
- )} + )} +
Testo del problema
+ + {solutions.length > 0 && ( +
+ Soluzioni +
+ {solutions.map(s => ( + + ))} +
+
+ )} + {user ? ( + <> +
Invia una soluzione al problema
+
+
+

Editor

+ +
+
+

Preview

+
+ {source.trim().length ? ( + + ) : ( +
Scrivi una nuova soluzione...
+ )} +
+
+
+
+
+ + ) : ( +
+ Accedi per inviare una soluzione
-
-
- -
+ )}
) } diff --git a/client/styles/main.scss b/client/styles/main.scss index e943f05..61c9e7d 100644 --- a/client/styles/main.scss +++ b/client/styles/main.scss @@ -69,17 +69,17 @@ button { border: 1px solid #c8c8c8; padding: 0.5rem 2rem; - box-shadow: -2px 4px 16px 0 #00000010, -2px 4px 4px 0 #00000010; + box-shadow: -2px 2px 16px 0 #00000010; border-radius: 0.25rem; - background: linear-gradient(180deg, #f0f0f0, #dfdfdf 20%, #d8d8d8 80%, #c0c0c0); + background: linear-gradient(180deg, #f0f0f0, #e8e8e8 20%, #e0e0e0 90%, #cdcdcd); transition: all 100ms ease-in; &:hover { border: 1px solid #c4c4c4; - box-shadow: -2px 4px 20px 4px #00000010, -2px 4px 6px 2px #00000010; - background: linear-gradient(180deg, #f8f8f8, #e4e4e4 20%, #e4e4e4 80%, #c8c8c8); + box-shadow: -2px 2px 20px 0 #00000010; + background: linear-gradient(180deg, #fff, #ededed 20%, #e8e8e8 90%, #c0c0c0); } } @@ -193,10 +193,28 @@ main { main.home { .board { width: 100%; + // min-width: 0; display: grid; - grid-template-columns: repeat(auto-fill, minmax(60ch, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(auto, 70ch)); gap: 2rem; + + justify-content: center; + } +} + +main.problem { + summary { + padding: 1rem; + text-align: center; + + user-select: none; + } + + .solution-list { + display: flex; + flex-direction: column; + gap: 1rem; } } @@ -207,7 +225,8 @@ main.home { main { .problem { padding: 1rem; - // border: 1px solid #ddd; + + max-width: 80ch; box-shadow: -2px 4px 8px 1px #00000020, -1px 1px 1px 0px #00000010; border-radius: 0.5rem; @@ -217,16 +236,23 @@ main { grid-template-rows: auto 1fr; gap: 0.5rem; - max-width: 80ch; - .problem-header { display: grid; grid-template-columns: auto; + gap: 0.25rem; .problem-title { font-size: 24px; font-weight: 700; } + + .problem-author { + grid-row: 2; + + font-size: 16px; + color: #000000dd; + // font-weight: 400; + } } .problem-content { @@ -234,6 +260,30 @@ main { } } + .solution { + padding: 1rem; + + max-width: 80ch; + + box-shadow: -2px 4px 8px 1px #00000020, -1px 1px 1px 0px #00000010; + border-radius: 0.5rem; + background: #ffffff; + + display: grid; + grid-template-rows: auto 1fr; + gap: 0.5rem; + + .solution-header { + display: grid; + grid-template-columns: auto; + gap: 0.25rem; + } + + .solution-content { + @extend .text-body; + } + } + .solution-editor { display: grid; width: 100%; @@ -291,13 +341,15 @@ main { .form { min-width: 50ch; + background: #e0e0e0; + display: grid; grid-template-columns: auto 1fr; - // border: 2px solid #ccc; + border: 1px solid #c0c0c0; border-radius: 1rem; - box-shadow: -2px 4px 16px 4px #00000018, -2px 4px 4px 1px #00000018; + // box-shadow: -2px 4px 16px 4px #00000018, -2px 4px 4px 1px #00000018; padding: 1.5rem 2rem 1rem; gap: 1rem; diff --git a/index.html b/index.html index 460314a..afc92aa 100644 --- a/index.html +++ b/index.html @@ -20,6 +20,9 @@ + + + diff --git a/server.js b/server.js index 02a0bb2..6c52fd1 100644 --- a/server.js +++ b/server.js @@ -8,7 +8,7 @@ import { createServer as createViteServer } from 'vite' import { createApiRouter } from './server/routes.js' -const HTML_ROUTES = ['/', '/login', '/problem/:id'] +const HTML_ROUTES = ['/', '/login', '/problem/:id', '/admin'] const config = { isDevelopment: process.env.MODE === 'development', @@ -48,9 +48,27 @@ async function createDevRouter() { // Load (to be bundled) entry point for server side rendering const { render } = await vite.ssrLoadModule('./client/entry-server.jsx') - const html = transformedTemplate.replace('', render(req.originalUrl)) - - res.send(html) + const { html, metadata } = render(req.originalUrl) + + process.stdout.write('[Metadata] ') + console.dir(metadata) + + const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}` + const metaTagsHtml = + '' + + (metadata.title + ? `\n` + : '') + + (metadata.description + ? `\n` + : '') + + `\n` + + res.send( + transformedTemplate + .replace('', metaTagsHtml) + .replace('', html) + ) } catch (error) { vite.ssrFixStacktrace(error) next(error) @@ -74,9 +92,9 @@ async function createProductionRouter() { 'utf-8' ) - const html = transformedTemplate.replace('', render(req.originalUrl)) + const { html, metadata } = render(req.originalUrl) - res.send(html) + res.send(transformedTemplate.replace('', html)) }) return r diff --git a/server/db/database.js b/server/db/database.js index 88ff336..063f9c2 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -1,15 +1,72 @@ +import crypto from 'crypto' + import { readFile, writeFile, access, constants } from 'fs/promises' +function once(fn, message) { + let flag = false + + return (...args) => { + if (flag) { + throw new Error(message ?? `cannot run more than once`) + } + flag = true + return fn(...args) + } +} + +function createMutex() { + let locked = false + const waiters = [] + + const unlock = () => { + if (waiters.length > 0) { + console.log(`[Mutex] Passing lock to next in queue (of size ${waiters.length})`) + const resolve = waiters.shift() + resolve(once(unlock, `lock already released`)) + } else { + locked = false + console.log(`[Mutex] Releasing the lock`) + } + } + + const lock = () => { + if (locked) { + console.log(`[Mutex] Putting into queue`) + return new Promise(resolve => { + waiters.push(resolve) + }) + } else { + console.log(`[Mutex] Acquiring the lock`) + locked = true + return once(unlock, `lock already released`) + } + } + + return { lock } +} + +// l = createLock() +// unlock1 = await l.lock() // immediate +// unlock2 = await l.lock() // hangs... +// unlock1() // l2 restarts... +// unlock2() // no waiters so noop + export function createDatabase(path, initialValue) { - return { path, initialValue } + return { + path, + initialValue, + mu: createMutex(), + } } -async function withDatabase({ path, initialValue }, fn) { +async function withDatabase({ path, initialValue, mu }, fn) { + const unlock = await mu.lock() + try { await access(path, constants.R_OK) } catch (e) { console.log(`[Database] Creating empty database into "${path}"`) - await writeFile(path, JSON.stringify(initialValue, null, 2)) + await writeFile(path, JSON.stringify(initialValue, null, 4)) } console.log(`[Database] Loading database from "${path}"`) @@ -18,40 +75,83 @@ async function withDatabase({ path, initialValue }, fn) { const result = await fn(state) console.log(`[Database] Saving database to "${path}"`) - await writeFile(path, JSON.stringify(state, null, 2)) + await writeFile(path, JSON.stringify(state, null, 4)) - return result -} + unlock() -function createTable(tableName) { - return { - create() {}, - get() {}, - update() {}, - delete() {}, - } + return result } -export const User = createTable('users') -export const Problem = createTable('problems') +export const getUsers = db => + withDatabase(db, state => { + return Object.values(state.users) + }) export const getUser = (db, id) => withDatabase(db, state => { - return state.users[id] + return state.users[id] ?? null + }) + +// export const createUser = (db, { email, username }) => +// withDatabase(db, state => { +// state.users[username] = { +// email, +// username, +// } +// }) + +// export const updateUser = (db, username, { email, password }) => +// withDatabase(db, state => { +// state.users[username] = { +// email, +// password, +// } +// }) + +// +// Problems +// + +export const getProblem = (db, id) => + withDatabase(db, state => { + return state.problems[id] }) -export const createUser = (db, { email, username, password }) => +export const getProblems = db => withDatabase(db, state => { - state.users[email] = { - username, - password, + return Object.values(state.problems) + }) + +// +// Solutions +// + +export const createSolution = (db, { userId, problemId, content }) => + withDatabase(db, state => { + const id = crypto.randomBytes(10).toString('hex') + state.solutions[id] = { + id, + userId, + problemId, + content, } }) -export const updateUser = (db, username, { email, password }) => +export const getSolution = (db, id) => + withDatabase(db, state => { + return state.solutions[id] + }) + +export const getSolutions = (db, { userId, problemId } = {}) => withDatabase(db, state => { - state.users[username] = { - email, - password, + let solutions = Object.values(state.solutions) + + if (userId) { + solutions = solutions.filter(s => s.userId === userId) } + if (problemId) { + solutions = solutions.filter(s => s.problemId === problemId) + } + + return solutions }) diff --git a/server/middlewares.js b/server/middlewares.js index cd46a88..10c7b7b 100644 --- a/server/middlewares.js +++ b/server/middlewares.js @@ -1,18 +1,17 @@ import { Router } from 'express' -import chalk from 'chalk' -import { toLocalISO } from '../utils/util.js' - -export class StatusRouter extends Router { - constructor() { - super() - - this.get('/', (req, res) => { - res.json({ status: 'ok' }) - }) - } +const createRouter = setup => options => { + const r = new Router() + setup(r, options) + return r } +export const createStatusRouter = createRouter(r => { + r.get('/', (req, res) => { + res.json({ url: req.originalUrl, status: 'ok' }) + }) +}) + export class PingRouter extends Router { constructor() { super() @@ -28,10 +27,16 @@ export class PingRouter extends Router { } } +export const INVALID_SESSION = `invalid session token` + export const authMiddleware = getUserForSession => async (req, res, next) => { if (req.cookies.sid) { - req.user = await getUserForSession(req.cookies.sid) - console.log('Request from user: ' + req.user) + const user = await getUserForSession(req.cookies.sid) + if (user) { + req.user = user + } else { + res.cookie('sid', '', { expires: new Date() }) + } } next() diff --git a/server/routes.js b/server/routes.js index e84a39d..440f529 100644 --- a/server/routes.js +++ b/server/routes.js @@ -5,8 +5,16 @@ import cookieParser from 'cookie-parser' import express from 'express' -import { authMiddleware, PingRouter, StatusRouter } from './middlewares.js' -import { createDatabase, getUser, updateUser } from './db/database.js' +import { createStatusRouter, PingRouter } from './middlewares.js' +import { + createDatabase, + createSolution, + getProblem, + getProblems, + getSolutions, + getUser, + getUsers, +} from './db/database.js' import { initialDatabaseValue } from './db/example-data.js' export async function createApiRouter() { @@ -24,35 +32,38 @@ export async function createApiRouter() { const db = createDatabase('./db.local.json', initialDatabaseValue) + async function getRequestUser(req) { + const userId = sessions.getUserForSession(req.cookies.sid) + if (!userId) { + return null + } + + const user = await getUser(db, userId) + + return user + } + const r = express.Router() r.use(bodyParser.json()) r.use(cookieParser()) - r.use(authMiddleware(sid => sessions.getUserForSession(sid))) - - r.use('/api/status', new StatusRouter()) + r.use('/api/status', createStatusRouter()) r.use('/api/ping', new PingRouter()) r.get('/api/current-user', async (req, res) => { - const userId = sessions.getUserForSession(req.cookies.sid) - if (!userId) { - res.cookie('sid', '', { expires: new Date() }) - res.status(400) - res.end('Invalid session token') - return - } - - const user = await getUser(db, userId) - - res.json({ - username: userId, - ...user, - }) + res.json(await getRequestUser(req)) }) - r.post('/api/login', (req, res) => { + r.post('/api/login', async (req, res) => { const { username } = req.body + + const user = await getUser(db, username) + if (!user) { + res.sendStatus(403) + return + } + res.cookie('sid', sessions.createSession(username), { maxAge: 1000 * 60 * 60 * 24 * 7 }) res.json({ status: 'ok' }) }) @@ -62,20 +73,83 @@ export async function createApiRouter() { res.json({ status: 'ok' }) }) + r.get('/api/users', async (req, res) => { + const requestUser = await getRequestUser(req) + if (requestUser.role !== 'admin' && requestUser.role !== 'moderator') { + res.sendStatus(401) + return + } + + const users = await getUsers(db) + res.json(users) + }) + + r.get('/api/problems', async (req, res) => { + res.json(await getProblems(db)) + }) + + r.get('/api/problem/:id', async (req, res) => { + res.json(await getProblem(db, req.params.id)) + }) + + r.get('/api/solutions', async (req, res) => { + let queryUserId = req.query.user + let queryProblemId = req.query.problem + + const requestUser = await getRequestUser(req) + if (!requestUser) { + res.sendStatus(401) + return + } + + // if current user is not an administrator then force the user query to current user + if (requestUser.role !== 'admin' && requestUser.role !== 'moderator') { + queryUserId = requestUser.username + } + + res.json(await getSolutions(db, { userId: queryUserId, problemId: queryProblemId })) + }) + + r.post('/api/solution', async (req, res) => { + const user = await getRequestUser(req) + if (!user) { + res.sendStatus(401) + return + } + + await createSolution(db, { + userId: user.username, + problemId: req.body.problemId, + content: req.body.content, + }) + + res.send({ status: 'ok' }) + }) + r.get('/api/user/:id', async (req, res) => { - const user = await getUser(db, req.params.id) + const requestUser = await getRequestUser(req) + if (!requestUser) { + res.sendStatus(401) + return + } + if (requestUser.role !== 'admin' && requestUser.role !== 'moderator') { + res.sendStatus(401) + return + } - if (user) { - res.json(user) - } else { + const requestedUser = await getUser(db, req.params.id) + if (!requestedUser) { res.sendStatus(404) + return } - }) - r.post('/api/user/:id', async (req, res) => { - await updateUser(db, req.params.id, req.body) - res.sendStatus(200) + res.json(requestedUser) }) + // r.post('/api/user/:id', async (req, res) => { + // await updateUser(db, req.params.id, req.body) + // res.sendStatus(200) + // }) + return r } diff --git a/utils/util.js b/utils/util.js index 9bc36db..5e7ea9b 100644 --- a/utils/util.js +++ b/utils/util.js @@ -1,9 +1,9 @@ export function toLocalISO(date) { - var tzo = -date.getTimezoneOffset(), - dif = tzo >= 0 ? '+' : '-', - pad = function (num) { - return (num < 10 ? '0' : '') + num - } + const tzo = -date.getTimezoneOffset() + const dif = tzo >= 0 ? '+' : '-' + const pad = function (num) { + return num.toString().padStart(2, '0') + } return ( date.getFullYear() +