Refactoring to Typescript...

pull/1/head
Antonio De Lucreziis 2 years ago
parent 48d1b3b4ed
commit d61f17a0f3

@ -1,14 +1,14 @@
import Router from 'preact-router'
import { route } from 'preact-router'
import { useEffect } from 'preact/hooks'
import { AdminPage } from './pages/Admin.jsx'
import { AdminPage } from './pages/Admin'
import { HomePage } from './pages/Home.jsx'
import { LoginPage } from './pages/Login.jsx'
import { ProblemPage } from './pages/Problem.jsx'
import { ProfilePage } from './pages/Profile.jsx'
import { HomePage } from './pages/Home'
import { LoginPage } from './pages/Login'
import { ProblemPage } from './pages/Problem'
import { ProfilePage } from './pages/Profile'
const Redirect = ({ to }) => {
const Redirect = ({ to }: { default: boolean; to: string }) => {
useEffect(() => {
route(to, true)
}, [])
@ -20,7 +20,7 @@ const Redirect = ({ to }) => {
)
}
export const App = ({ url }) => {
export const App = ({ url }: { url?: string }) => {
return (
<Router url={url}>
<HomePage path="/" />

@ -1,8 +1,9 @@
import { Link } from 'preact-router/match'
import { isAdministrator, USER_ROLE_ADMIN, USER_ROLE_MODERATOR } from '../../shared/constants.js'
const ROLE_LABEL = {
admin: 'Admin',
moderator: 'Moderatore',
[USER_ROLE_ADMIN]: 'Admin',
[USER_ROLE_MODERATOR]: 'Moderatore',
}
export const Header = ({ user, noLogin }) => (
@ -13,7 +14,7 @@ export const Header = ({ user, noLogin }) => (
<nav>
{user ? (
<>
{user.role !== 'student' && (
{isAdministrator(user.role) && (
<div class="nav-item">
<Link activeClassName="active" href="/admin">
Pannello Admin
@ -22,8 +23,8 @@ export const Header = ({ user, noLogin }) => (
)}
<div class="nav-item">
<Link activeClassName="active" href="/profile">
@{user.username}
{user.role !== 'student' && <> ({ROLE_LABEL[user.role]})</>}
@{user.id}
{isAdministrator(user.role) && <> ({ROLE_LABEL[user.role]})</>}
</Link>
</div>
</>

@ -1,6 +1,6 @@
import { Markdown } from './Markdown.jsx'
export const Problem = ({ id, content, createdBy }) => {
export const Problem = ({ id, content, solutionsCount }) => {
return (
<div class="problem">
<div class="problem-header">
@ -11,6 +11,11 @@ export const Problem = ({ id, content, createdBy }) => {
<div class="problem-content">
<Markdown source={content} />
</div>
{solutionsCount > 0 && (
<div class="problem-footer">
{solutionsCount} soluzion{solutionsCount === 1 ? 'e' : 'i'}
</div>
)}
</div>
)
}

@ -1,6 +1,10 @@
import { refToUserId } from '../../shared/db-refs.js'
import { Markdown } from './Markdown.jsx'
export const Solution = ({ userId, problemId, content }) => {
export const Solution = ({ sentBy, forProblem, content }) => {
const userId = refToUserId(sentBy)
const problemId = refToUserId(forProblem)
return (
<div class="solution">
<div class="solution-header">

@ -1,4 +0,0 @@
import { hydrate } from 'preact'
import { App } from './App.jsx'
hydrate(<App />, document.body)

@ -0,0 +1,4 @@
import { hydrate } from 'preact'
import { App } from './App'
// hydrate(<App />, document.body)

@ -1,13 +1,15 @@
import renderToString from 'preact-render-to-string'
import { App } from './App.jsx'
import { MetadataContext } from './hooks.jsx'
// import { App } from './App'
import { MetadataContext } from './hooks'
export function render(url) {
import { RenderedPage } from '../shared/ssr'
export default (url: string): RenderedPage => {
const metadata = {}
const html = renderToString(
<MetadataContext.Provider value={metadata}>
<App url={url} />
{/* <App url={url} /> */}
</MetadataContext.Provider>
)

@ -1,5 +1,6 @@
import { route } from 'preact-router'
import { useEffect, useState } from 'preact/hooks'
import { isAdministrator, isStudent } from '../../shared/constants.js'
import { server } from '../api.jsx'
import { Header } from '../components/Header.jsx'
import { MarkdownEditor } from '../components/MarkdownEditor.jsx'
@ -27,7 +28,7 @@ export const AdminPage = ({}) => {
const [user] = useCurrentUser(user => {
if (!user) {
route('/login', true)
} else if (user.role !== 'admin' && user.role !== 'moderator') {
} else if (isStudent(user.role)) {
route('/', true)
}
})

@ -10,6 +10,8 @@ export const HomePage = () => {
const [user] = useCurrentUser()
const [problems] = useReadResource('/api/problems', [])
console.log(problems)
return (
<main class="page-home">
<Header {...{ user }} />

@ -13,7 +13,7 @@ export const LoginPage = () => {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username,
id: username,
}),
})

@ -20,7 +20,7 @@ export const ProblemPage = ({ id }) => {
const sendSolution = async () => {
await server.post('/api/solution', {
problemId: id,
forProblem: id,
content: source,
})

@ -13,7 +13,7 @@ export const ProfilePage = ({}) => {
route('/login', true)
}
setSolutions(await server.get(`/api/solutions?user=${user.username}`))
setSolutions(await server.get(`/api/solutions?user=${user.id}`))
})
const handleLogout = () => {
@ -27,8 +27,8 @@ export const ProfilePage = ({}) => {
<Header {...{ user }} />
<div class="subtitle">Le tue soluzioni</div>
<div class="solution-list">
{solutions.map(({ problemId, content }) => (
<Solution {...{ problemId, content }} />
{solutions.map(({ forProblem, content }) => (
<Solution {...{ forProblem, content }} />
))}
</div>
<div class="subtitle">Altro</div>

@ -22,7 +22,7 @@ body {
font-family: 'Lato', sans-serif;
font-weight: 300;
font-size: 20px;
font-size: 18px;
line-height: 1;
}
@ -70,7 +70,7 @@ input[type='text'] {
cursor: pointer;
font-family: 'Lato';
font-weight: 600;
font-size: 18px;
font-size: 16px;
color: #555;
border: 1px solid #c8c8c8;
@ -102,7 +102,7 @@ input[type='text'] {
font-family: 'Lato';
font-weight: 600;
font-size: 18px;
font-size: 16px;
color: #555;
}
}
@ -112,7 +112,7 @@ button {
font-family: 'Lato';
font-weight: 600;
font-size: 18px;
font-size: 16px;
color: #555;
border: 1px solid #c8c8c8;
@ -199,7 +199,7 @@ $heading-scale: 1.25;
.text-body {
font-family: 'EB Garamond', serif;
font-weight: 400;
font-size: 21px;
font-size: 18px;
line-height: 1.41;
@ -232,7 +232,7 @@ main {
gap: 2rem;
.subtitle {
font-size: 24px;
font-size: 22px;
}
}
@ -301,6 +301,9 @@ header {
display: flex;
gap: 1rem;
height: 100%;
align-items: center;
.nav-item {
font-size: 24px;
font-weight: 300;
@ -327,7 +330,7 @@ header {
background: #ffffff;
display: grid;
grid-template-rows: auto 1fr;
grid-template-rows: auto 1fr auto;
gap: 0.5rem;
.problem-header {
@ -336,7 +339,7 @@ header {
gap: 0.25rem;
.problem-title {
font-size: 24px;
font-size: 22px;
font-weight: 400;
}
}
@ -344,6 +347,10 @@ header {
.problem-content {
@extend .text-body;
}
.problem-footer {
font-size: 16px;
}
}
.solution {
@ -420,7 +427,7 @@ header {
textarea {
font-family: 'DM Mono', monospace;
font-size: 18px;
font-size: 16px;
resize: none;
overflow-y: hidden;

@ -30,6 +30,6 @@
</head>
<body>
<!-- SSR OUTLET -->
<script type="module" src="/client/entry-client.jsx"></script>
<script type="module" src="/client/entry-client.tsx"></script>
</body>
</html>

@ -5,11 +5,13 @@
"main": "index.js",
"type": "module",
"scripts": {
"dev": "MODE=development node server.js",
"build": "run-s build:client build:server",
"build:client": "vite build --outDir dist/client",
"build:server": "vite build --ssr client/entry-server.jsx --outDir dist/server",
"serve": "node server.js"
"dev": "run-s build:server serve:dev",
"build": "run-s build:client build:ssr build:server",
"build:server": "esbuild server.ts --bundle --platform=node --format=esm --external:./node_modules/* --outdir=dist/server",
"build:client": "vite build --outDir dist/entry-client",
"build:ssr": "vite build --ssr client/entry-server.tsx --outDir dist/entry-server",
"serve:dev": "MODE=development node dist/server/server.js",
"serve": "node dist/server/server.js"
},
"license": "MIT",
"dependencies": {
@ -35,7 +37,13 @@
"vite": "^3.2.2"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.3",
"@types/express": "^4.17.14",
"@types/morgan": "^1.9.3",
"@types/node": "^18.11.9",
"concurrently": "^7.5.0",
"npm-run-all": "^4.1.5"
"esbuild": "^0.15.13",
"npm-run-all": "^4.1.5",
"typescript": "^4.8.4"
}
}

@ -3,10 +3,15 @@ lockfileVersion: 5.4
specifiers:
'@preact/preset-vite': ^2.4.0
'@preact/signals': ^1.1.2
'@types/cookie-parser': ^1.4.3
'@types/express': ^4.17.14
'@types/morgan': ^1.9.3
'@types/node': ^18.11.9
body-parser: ^1.20.1
chalk: ^5.1.2
concurrently: ^7.5.0
cookie-parser: ^1.4.6
esbuild: ^0.15.13
express: ^4.18.2
katex: ^0.16.3
morgan: ^1.10.0
@ -20,6 +25,7 @@ specifiers:
remark-parse: ^10.0.1
remark-rehype: ^10.1.0
sass: ^1.55.0
typescript: ^4.8.4
unified: ^10.1.2
url-pattern: ^1.0.3
vite: ^3.2.2
@ -47,8 +53,14 @@ dependencies:
vite: 3.2.2_sass@1.56.0
devDependencies:
'@types/cookie-parser': 1.4.3
'@types/express': 4.17.14
'@types/morgan': 1.9.3
'@types/node': 18.11.9
concurrently: 7.5.0
esbuild: 0.15.13
npm-run-all: 4.1.5
typescript: 4.8.4
packages:
@ -302,7 +314,6 @@ packages:
cpu: [arm]
os: [android]
requiresBuild: true
dev: false
optional: true
/@esbuild/linux-loong64/0.15.13:
@ -311,7 +322,6 @@ packages:
cpu: [loong64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@jridgewell/gen-mapping/0.1.1:
@ -426,12 +436,48 @@ packages:
picomatch: 2.3.1
dev: false
/@types/body-parser/1.19.2:
resolution: {integrity: sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==}
dependencies:
'@types/connect': 3.4.35
'@types/node': 18.11.9
dev: true
/@types/connect/3.4.35:
resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==}
dependencies:
'@types/node': 18.11.9
dev: true
/@types/cookie-parser/1.4.3:
resolution: {integrity: sha512-CqSKwFwefj4PzZ5n/iwad/bow2hTCh0FlNAeWLtQM3JA/NX/iYagIpWG2cf1bQKQ2c9gU2log5VUCrn7LDOs0w==}
dependencies:
'@types/express': 4.17.14
dev: true
/@types/debug/4.1.7:
resolution: {integrity: sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==}
dependencies:
'@types/ms': 0.7.31
dev: false
/@types/express-serve-static-core/4.17.31:
resolution: {integrity: sha512-DxMhY+NAsTwMMFHBTtJFNp5qiHKJ7TeqOo23zVEM9alT1Ml27Q3xcTH0xwxn7Q0BbMcVEJOs/7aQtUWupUQN3Q==}
dependencies:
'@types/node': 18.11.9
'@types/qs': 6.9.7
'@types/range-parser': 1.2.4
dev: true
/@types/express/4.17.14:
resolution: {integrity: sha512-TEbt+vaPFQ+xpxFLFssxUDXj5cWCxZJjIcB7Yg0k0GMHGtgtQgpvx/MUQUeAkNbA9AAGrwkAsoeItdTgS7FMyg==}
dependencies:
'@types/body-parser': 1.19.2
'@types/express-serve-static-core': 4.17.31
'@types/qs': 6.9.7
'@types/serve-static': 1.15.0
dev: true
/@types/hast/2.3.4:
resolution: {integrity: sha512-wLEm0QvaoawEDoTRwzTXp4b4jpwiJDvR5KMnFnVodm3scufTlBOWRD6N1OBf9TZMhjlNsSfcO5V+7AF4+Vy+9g==}
dependencies:
@ -448,14 +494,43 @@ packages:
'@types/unist': 2.0.6
dev: false
/@types/mime/3.0.1:
resolution: {integrity: sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==}
dev: true
/@types/morgan/1.9.3:
resolution: {integrity: sha512-BiLcfVqGBZCyNCnCH3F4o2GmDLrpy0HeBVnNlyZG4fo88ZiE9SoiBe3C+2ezuwbjlEyT+PDZ17//TAlRxAn75Q==}
dependencies:
'@types/node': 18.11.9
dev: true
/@types/ms/0.7.31:
resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==}
dev: false
/@types/node/18.11.9:
resolution: {integrity: sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==}
dev: true
/@types/parse5/6.0.3:
resolution: {integrity: sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==}
dev: false
/@types/qs/6.9.7:
resolution: {integrity: sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==}
dev: true
/@types/range-parser/1.2.4:
resolution: {integrity: sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==}
dev: true
/@types/serve-static/1.15.0:
resolution: {integrity: sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==}
dependencies:
'@types/mime': 3.0.1
'@types/node': 18.11.9
dev: true
/@types/unist/2.0.6:
resolution: {integrity: sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==}
dev: false
@ -872,7 +947,6 @@ packages:
cpu: [x64]
os: [android]
requiresBuild: true
dev: false
optional: true
/esbuild-android-arm64/0.15.13:
@ -881,7 +955,6 @@ packages:
cpu: [arm64]
os: [android]
requiresBuild: true
dev: false
optional: true
/esbuild-darwin-64/0.15.13:
@ -890,7 +963,6 @@ packages:
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: false
optional: true
/esbuild-darwin-arm64/0.15.13:
@ -899,7 +971,6 @@ packages:
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: false
optional: true
/esbuild-freebsd-64/0.15.13:
@ -908,7 +979,6 @@ packages:
cpu: [x64]
os: [freebsd]
requiresBuild: true
dev: false
optional: true
/esbuild-freebsd-arm64/0.15.13:
@ -917,7 +987,6 @@ packages:
cpu: [arm64]
os: [freebsd]
requiresBuild: true
dev: false
optional: true
/esbuild-linux-32/0.15.13:
@ -926,7 +995,6 @@ packages:
cpu: [ia32]
os: [linux]
requiresBuild: true
dev: false
optional: true
/esbuild-linux-64/0.15.13:
@ -935,7 +1003,6 @@ packages:
cpu: [x64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/esbuild-linux-arm/0.15.13:
@ -944,7 +1011,6 @@ packages:
cpu: [arm]
os: [linux]
requiresBuild: true
dev: false
optional: true
/esbuild-linux-arm64/0.15.13:
@ -953,7 +1019,6 @@ packages:
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/esbuild-linux-mips64le/0.15.13:
@ -962,7 +1027,6 @@ packages:
cpu: [mips64el]
os: [linux]
requiresBuild: true
dev: false
optional: true
/esbuild-linux-ppc64le/0.15.13:
@ -971,7 +1035,6 @@ packages:
cpu: [ppc64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/esbuild-linux-riscv64/0.15.13:
@ -980,7 +1043,6 @@ packages:
cpu: [riscv64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/esbuild-linux-s390x/0.15.13:
@ -989,7 +1051,6 @@ packages:
cpu: [s390x]
os: [linux]
requiresBuild: true
dev: false
optional: true
/esbuild-netbsd-64/0.15.13:
@ -998,7 +1059,6 @@ packages:
cpu: [x64]
os: [netbsd]
requiresBuild: true
dev: false
optional: true
/esbuild-openbsd-64/0.15.13:
@ -1007,7 +1067,6 @@ packages:
cpu: [x64]
os: [openbsd]
requiresBuild: true
dev: false
optional: true
/esbuild-sunos-64/0.15.13:
@ -1016,7 +1075,6 @@ packages:
cpu: [x64]
os: [sunos]
requiresBuild: true
dev: false
optional: true
/esbuild-windows-32/0.15.13:
@ -1025,7 +1083,6 @@ packages:
cpu: [ia32]
os: [win32]
requiresBuild: true
dev: false
optional: true
/esbuild-windows-64/0.15.13:
@ -1034,7 +1091,6 @@ packages:
cpu: [x64]
os: [win32]
requiresBuild: true
dev: false
optional: true
/esbuild-windows-arm64/0.15.13:
@ -1043,7 +1099,6 @@ packages:
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: false
optional: true
/esbuild/0.15.13:
@ -1074,7 +1129,6 @@ packages:
esbuild-windows-32: 0.15.13
esbuild-windows-64: 0.15.13
esbuild-windows-arm64: 0.15.13
dev: false
/escalade/3.1.1:
resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==}
@ -2489,6 +2543,12 @@ packages:
mime-types: 2.1.35
dev: false
/typescript/4.8.4:
resolution: {integrity: sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==}
engines: {node: '>=4.2.0'}
hasBin: true
dev: true
/unbox-primitive/1.0.2:
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
dependencies:

@ -7,6 +7,7 @@ import morgan from 'morgan'
import { createServer as createViteServer } from 'vite'
import { createApiRouter } from './server/routes.js'
import { RenderFunction } from './shared/ssr.js'
const HTML_ROUTES = ['/', '/login', '/problem/:id', '/admin', '/profile']
@ -46,7 +47,8 @@ async function createDevRouter() {
const transformedTemplate = await vite.transformIndexHtml(req.originalUrl, indexHtml)
// Load (to be bundled) entry point for server side rendering
const { render } = await vite.ssrLoadModule('./client/entry-server.jsx')
const render: RenderFunction = (await vite.ssrLoadModule('./client/entry-server.tsx'))
.default
const { html, metadata } = render(req.originalUrl)
@ -80,9 +82,10 @@ async function createDevRouter() {
async function createProductionRouter() {
// Load bundled entry point for server side rendering
const { render } = await import('./dist/server/entry-server.js')
const render: RenderFunction = ((await import('./dist/entry-server/entry-server.js')) as any)
.default
const r = new express.Router()
const r = express.Router()
r.use('/', express.static('dist/client'))

@ -2,21 +2,39 @@ import crypto from 'crypto'
import { readFile, writeFile, access, constants } from 'fs/promises'
function once(fn, message) {
import {
CommonProps as MetaProps,
Problem,
ProblemId,
Solution,
SolutionId,
User,
UserId,
} from '../../shared/model'
function once<T extends (...args: any) => any>(fn: T, message: string): T {
let flag = false
return (...args) => {
return ((...args: any) => {
if (flag) {
throw new Error(message ?? `cannot run more than once`)
}
flag = true
return fn(...args)
}
}) as T
}
interface Lock {
(): void
}
interface Mutex {
lock(): Promise<Lock>
}
function createMutex() {
function createMutex(): Mutex {
let locked = false
const waiters = []
const waiters: ((fn: () => void) => void)[] = []
const unlock = () => {
if (waiters.length > 0) {
@ -29,7 +47,7 @@ function createMutex() {
}
}
const lock = () => {
const lock = (): Promise<Lock> => {
if (locked) {
console.log(`[Mutex] Putting into queue`)
return new Promise(resolve => {
@ -38,20 +56,26 @@ function createMutex() {
} else {
console.log(`[Mutex] Acquiring the lock`)
locked = true
return once(unlock, `lock already released`)
return Promise.resolve(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
type DatabaseConnection = {
path: string
initialValue: Database
mu: Mutex
}
export type Database = {
users: Record<string, User>
problems: Record<string, Problem>
solutions: Record<string, Solution>
}
export function createDatabase(path, initialValue) {
export function createDatabase(path: string, initialValue: Database) {
return {
path,
initialValue,
@ -59,8 +83,11 @@ export function createDatabase(path, initialValue) {
}
}
async function withDatabase({ path, initialValue, mu }, fn) {
const unlock = await mu.lock()
async function withDatabase<R>(
{ path, initialValue, mu }: DatabaseConnection,
fn: (db: Database) => R | Promise<R>
): Promise<R> {
const unlock: Lock = await mu.lock()
try {
await access(path, constants.R_OK)
@ -82,43 +109,30 @@ async function withDatabase({ path, initialValue, mu }, fn) {
return result
}
export const getUsers = db =>
//
// Users
//
export const getUsers = (db: DatabaseConnection) =>
withDatabase(db, state => {
return Object.values(state.users)
})
export const getUser = (db, id) =>
withDatabase(db, state => {
export const getUser = (db: DatabaseConnection, id: string) =>
withDatabase(db, (state: Database): User | null => {
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 createProblem = (db, { content, createdBy }) =>
export const createProblem = (
db: DatabaseConnection,
{ content, createdBy }: Omit<Problem, 'id' | 'createdAt'>
): Promise<ProblemId> =>
withDatabase(db, state => {
const nextId = (
Object.keys(state.problems)
.map(k => parseInt(k))
.reduce((acc, id) => Math.max(acc, id)) + 1
).toString()
const nextId = (Object.keys(state.problems).length + 1).toString() as ProblemId
state.problems[nextId] = {
id: nextId,
@ -130,12 +144,12 @@ export const createProblem = (db, { content, createdBy }) =>
return nextId
})
export const getProblem = (db, id) =>
export const getProblem = (db: DatabaseConnection, id: string): Promise<Problem> =>
withDatabase(db, state => {
return state.problems[id]
})
export const getProblems = db =>
export const getProblems = (db: DatabaseConnection): Promise<Problem[]> =>
withDatabase(db, state => {
return Object.values(state.problems)
})
@ -144,31 +158,52 @@ export const getProblems = db =>
// Solutions
//
export const createSolution = (db, { userId, problemId, content }) =>
export const createSolution = (
db: DatabaseConnection,
{ sentBy, forProblem, content }: Omit<Solution, MetaProps>
): Promise<SolutionId> =>
withDatabase(db, state => {
const id = crypto.randomBytes(10).toString('hex')
state.solutions[id] = {
id,
userId,
problemId,
sentBy,
forProblem,
content,
status: 'pending',
}
return id
})
export const getSolution = (db, id) =>
export const getSolution = (db: DatabaseConnection, id: SolutionId): Promise<Solution> =>
withDatabase(db, state => {
return state.solutions[id]
})
export const getSolutions = (db, { userId, problemId } = {}) =>
export const updateSolution = (
db: DatabaseConnection,
id: SolutionId,
solution: Omit<Solution, MetaProps>
): Promise<Solution> =>
withDatabase(db, state => {
state.solutions[id] = { id, ...solution }
return state.solutions[id]
})
type SolutionsQuery = Partial<{
sentBy: UserId
forProblem: ProblemId
}>
export const getSolutions = (db: DatabaseConnection, { sentBy, forProblem }: SolutionsQuery = {}) =>
withDatabase(db, state => {
let solutions = Object.values(state.solutions)
if (userId) {
solutions = solutions.filter(s => s.userId === userId)
if (sentBy) {
solutions = solutions.filter(s => s.sentBy === sentBy)
}
if (problemId) {
solutions = solutions.filter(s => s.problemId === problemId)
if (forProblem) {
solutions = solutions.filter(s => s.forProblem === forProblem)
}
return solutions

@ -1,7 +0,0 @@
export const initialDatabaseValue = {
users: {
['BachoSeven']: {},
['aziis98']: {},
},
problems: {},
}

@ -0,0 +1,17 @@
import { UserId } from '../../shared/model'
import { Database } from './database'
export const initialDatabaseValue: Database = {
users: {
['BachoSeven']: {
id: 'BachoSeven' as UserId,
role: 'admin',
},
['aziis98']: {
id: 'aziis98' as UserId,
role: 'admin',
},
},
problems: {},
solutions: {},
}

@ -1,47 +0,0 @@
import { Router } from 'express'
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()
this.post('/', (req, res) => {
if (req.body?.message === 'ping') {
res.json('pong')
} else {
res.status(400)
res.json('Invalid request')
}
})
}
}
export const INVALID_SESSION = `invalid session token`
export const authMiddleware = getUserForSession => async (req, res, next) => {
if (req.cookies.sid) {
const user = await getUserForSession(req.cookies.sid)
if (user) {
req.user = user
} else {
res.cookie('sid', '', { expires: new Date() })
}
}
next()
}
export const authenticatedMiddleware = (req, res, next) => {
req.user && next()
}

@ -0,0 +1,61 @@
import express, { NextFunction, Request, Response, Router } from 'express'
import { User } from '../shared/model'
const createRouter =
<T>(setup: (r: Router, options?: T) => void) =>
(options: T) => {
const r = express.Router()
setup(r, options)
return r
}
export const createStatusRouter = createRouter(r => {
r.get('/', (req, res) => {
res.json({ url: req.originalUrl, status: 'ok' })
})
}) as () => Router
// export class PingRouter extends express.Router {
// constructor() {
// super()
// this.post('/', (req, res) => {
// if (req.body?.message === 'ping') {
// res.json('pong')
// } else {
// res.status(400)
// res.json('Invalid request')
// }
// })
// }
// }
export const INVALID_SESSION = `invalid session token`
export const authMiddleware =
<U>(getUserForSession: (sid: string) => U) =>
async (req: Request, res: Response, next: NextFunction) => {
if (req.cookies.sid) {
const user = await getUserForSession(req.cookies.sid)
if (user) {
// @ts-ignore
req.user = user
} else {
res.cookie('sid', '', { expires: new Date() })
}
}
next()
}
interface AuthenticatedRequest extends Request {
user: User | null
}
export const authenticatedMiddleware = (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
) => {
req.user && next()
}

@ -1,170 +0,0 @@
import crypto from 'crypto'
import bodyParser from 'body-parser'
import cookieParser from 'cookie-parser'
import express from 'express'
import { createStatusRouter, PingRouter } from './middlewares.js'
import {
createDatabase,
createProblem,
createSolution,
getProblem,
getProblems,
getSolutions,
getUser,
getUsers,
} from './db/database.js'
import { initialDatabaseValue } from './db/example-data.js'
export async function createApiRouter() {
const sessions = {
store: {},
createSession(username) {
const sid = crypto.randomBytes(10).toString('hex')
this.store[sid] = username
return sid
},
getUserForSession(sid) {
return this.store[sid] ?? null
},
}
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('/api/status', createStatusRouter())
r.use('/api/ping', new PingRouter())
r.get('/api/current-user', async (req, res) => {
res.json(await getRequestUser(req))
})
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' })
})
r.post('/api/logout', (req, res) => {
res.cookie('sid', '', { expires: new Date() })
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.post('/api/problem', async (req, res) => {
const user = await getRequestUser(req)
if (!user) {
res.sendStatus(401)
return
}
if (user.role !== 'admin' && user.role !== 'moderator') {
res.sendStatus(401)
return
}
const id = await createProblem(db, {
content: req.body.content,
createBy: user.username,
})
res.json(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 requestUser = await getRequestUser(req)
if (!requestUser) {
res.sendStatus(401)
return
}
if (requestUser.role !== 'admin' && requestUser.role !== 'moderator') {
res.sendStatus(401)
return
}
const requestedUser = await getUser(db, req.params.id)
if (!requestedUser) {
res.sendStatus(404)
return
}
res.json(requestedUser)
})
return r
}

@ -0,0 +1,249 @@
import crypto from 'crypto'
import bodyParser from 'body-parser'
import cookieParser from 'cookie-parser'
import express, { Request, Response, Router } from 'express'
import { createStatusRouter } from './middlewares'
import {
createDatabase,
createProblem,
createSolution,
getProblem,
getProblems,
getSolution,
getSolutions,
getUser,
getUsers,
updateSolution,
} from './db/database'
import { isAdministrator, isStudent, Problem, ProblemId, UserId } from '../shared/model'
import { initialDatabaseValue } from './db/example-data'
export async function createApiRouter() {
type SessionId = string
const sessions = {
store: {},
createSession(userId: UserId) {
const sid = crypto.randomBytes(10).toString('hex')
this.store[sid] = userId
return sid
},
getUserForSession(sid: SessionId) {
return this.store[sid] ?? null
},
}
const db = createDatabase('./db.local.json', initialDatabaseValue)
async function getRequestUser(req: Request) {
const userId = sessions.getUserForSession(req.cookies.sid)
if (!userId) {
return null
}
console.log(userId)
return await getUser(db, userId)
}
const r: Router = express.Router()
r.use(bodyParser.json())
r.use(cookieParser())
r.use('/api/status', createStatusRouter())
// r.use('/api/ping', new PingRouter())
r.get('/api/current-user', async (req, res) => {
res.json(await getRequestUser(req))
})
r.post('/api/login', async (req, res) => {
const { id } = req.body
const user = await getUser(db, id)
if (!user) {
res.sendStatus(403)
return
}
res.cookie('sid', sessions.createSession(id), { maxAge: 1000 * 60 * 60 * 24 * 7 })
res.json({ status: 'ok' })
})
r.post('/api/logout', (req, res) => {
res.cookie('sid', '', { expires: new Date() })
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) => {
type ProblemWithSolutionsCount = Problem & { solutionsCount?: number }
const problems: ProblemWithSolutionsCount[] = await getProblems(db)
const solutions = await getSolutions(db)
const solutionCounts: Record<ProblemId, number> = {}
for (const s of solutions) {
solutionCounts[s.forProblem] ||= 0
solutionCounts[s.forProblem]++
}
for (const p of problems) {
p.solutionsCount = solutionCounts[p.id] || 0
}
res.json(problems)
})
r.get('/api/problem/:id', async (req, res) => {
res.json(await getProblem(db, req.params.id))
})
r.post('/api/problem', async (req, res) => {
const user = await getRequestUser(req)
if (!user) {
res.sendStatus(401)
return
}
if (user.role !== 'admin' && user.role !== 'moderator') {
res.sendStatus(401)
return
}
const id = await createProblem(db, {
content: req.body.content,
createdBy: user.id,
})
res.json(id)
})
r.get('/api/solution/:id', async (req, res) => {
const user = await getRequestUser(req)
// l'utente deve essere loggato
if (!user) {
res.sendStatus(401)
return
}
const solution = await getSolution(db, req.params.id)
// uno studente che prova a ottenere la soluzione di un altro utente
if (!isAdministrator(user.role) && solution.sentBy !== user.id) {
res.sendStatus(401)
return
}
res.json(solution)
})
r.get('/api/solutions', async (req, res) => {
let queryUserId = req.query.user as string
let queryProblemId = req.query.problem as string
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 (!isAdministrator(requestUser.role)) {
queryUserId = requestUser.id
}
res.json(
await getSolutions(db, {
sentBy: queryUserId as UserId,
forProblem: queryProblemId as ProblemId,
})
)
})
r.post('/api/solution', async (req, res) => {
const user = await getRequestUser(req)
if (!user) {
res.sendStatus(401)
return
}
await createSolution(db, {
sentBy: user.id,
forProblem: req.body.problemId,
content: req.body.content,
status: 'pending',
})
res.send({ status: 'ok' })
})
r.post('/api/solution/:id', async (req, res) => {
const user = await getRequestUser(req)
// l'utente deve essere loggato
if (!user) {
res.sendStatus(401)
return
}
const solutionId = req.params.id
const solution = await getSolution(db, solutionId)
// uno studente non può modificare una soluzione di un altro utente
if (isStudent(user.role) && solution.sentBy !== user.id) {
res.sendStatus(401)
return
}
// modifico la soluzione con il json mandato dal client nel body della richiesta
await updateSolution(db, solutionId, req.body)
res.json({ status: 'ok' })
})
r.get('/api/user/:id', async (req, res) => {
const user = await getRequestUser(req)
// intanto l'utente deve essere loggato
if (!user) {
res.sendStatus(401)
return
}
// solo gli amministratori possono usare questa route
if (!isAdministrator(user.role)) {
res.sendStatus(401)
return
}
const requestedUser = await getUser(db, req.params.id)
// l'utente richiesto magari deve esistere
if (!requestedUser) {
res.sendStatus(404)
return
}
res.json(requestedUser)
})
return r
}

@ -0,0 +1,25 @@
import { ProblemId, SolutionId, UserId } from './model'
// export function refToUserId(ref: string): UserId {
// if (!ref.startsWith('user/')) {
// throw new Error(`"${ref}" is not a reference to a User`)
// }
// return ref.slice('user/'.length)
// }
// export function refToProblemId(ref: string): ProblemId {
// if (!ref.startsWith('problem/')) {
// throw new Error(`"${ref}" is not a reference to a Problem`)
// }
// return ref.slice('problem/'.length)
// }
// export function refToSolutionId(ref: string): SolutionId {
// if (!ref.startsWith('solution/')) {
// throw new Error(`"${ref}" is not a reference to a Solution`)
// }
// return ref.slice('solution/'.length)
// }

@ -0,0 +1,62 @@
//
// Common
//
export type CommonProps = 'id' | 'createdAt'
type Opaque<T, K, L extends string = 'opaque'> = T & { _: K; __: L }
type Id<T> = Opaque<string, T, 'id'>
//
// Users
//
export type AdministratorRole = 'admin' | 'moderator'
export type StudentRole = 'student'
export type UserRole = AdministratorRole | StudentRole
export type UserId = Id<User>
export type User = {
id: UserId
role: UserRole
}
export function isAdministrator(role: UserRole): role is AdministratorRole {
return role !== 'student'
}
export function isStudent(role: UserRole): role is StudentRole {
return role === 'student'
}
//
// Problems
//
export type ProblemId = Id<Problem>
export type Problem = {
id: ProblemId
content: string
createdBy: UserId
createdAt: string
}
//
// Solutions
//
export type SolutionStatus = 'pending' | 'rejected' | 'accepted'
export type SolutionId = string
export type Solution = {
id: SolutionId
sentBy: UserId
forProblem: ProblemId
content: string
status: SolutionStatus
}

@ -0,0 +1,6 @@
export type RenderedPage = {
html: string
metadata: any
}
export type RenderFunction = (url: string) => RenderedPage

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"noImplicitAny": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"jsxImportSource": "preact"
},
"include": ["client", "shared"],
"references": [{ "path": "./tsconfig.node.json" }]
}

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"noImplicitAny": true,
"allowSyntheticDefaultImports": true
},
"include": ["server", "shared"]
}
Loading…
Cancel
Save