+ 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() +