Compare commits

...

16 Commits
main ... dev

Author SHA1 Message Date
Francesco Minnocci 12687ba08c
Fix Dockerfile 2 years ago
Antonio De Lucreziis 1430c57423 Aggiunte un paio di cose al modello del db 3 years ago
Antonio De Lucreziis 1682cdca81 In alcuni punti go deduce da solo i tipi e ulteriori aggiunte a database-model.ts 3 years ago
Francesco Minnocci 7387307ad9
Merge branch 'dev' of git.phc.dm.unipi.it:phc/posti-dm into dev 3 years ago
Francesco Minnocci 6d4c742d4c
util/sets.go: Use generics Sets 3 years ago
Antonio De Lucreziis 3d10f70d71 Merge branch 'dev' of git.phc.dm.unipi.it:phc/posti-dm into dev 3 years ago
Antonio De Lucreziis 44ffecbdd2 Added a new typescript file describing the model of the new DB 3 years ago
Francesco Minnocci f670c78337
auth.go: make user of generics in RequestUser 3 years ago
Antonio De Lucreziis d5aa7c0ba2 chore: better makefile 3 years ago
Antonio De Lucreziis 2a76e0621b fix: added missing target files in vite.config.js and removed a compilation warning 3 years ago
Antonio De Lucreziis 35d42b8316 The generic zero function now is a bit more comprehensible 3 years ago
Antonio De Lucreziis 10704d946f Now server/auth is completely generic on the user type 3 years ago
Antonio De Lucreziis ff0e0a5dcc Preparing for generics 3 years ago
Antonio De Lucreziis 1495a5e45b Migliorata la struttura della libreria che gestisce le sessioni di autenticazione 3 years ago
Antonio De Lucreziis a970cea91a Aggiunte alcune delle nuove pagine 3 years ago
Antonio De Lucreziis fb2db34a9b Minor js riorganization and dev setup enhancements 3 years ago

@ -7,4 +7,4 @@ RUN make all
WORKDIR /app/dist
CMD ["./posti-dm"]
EXPOSE 3000
EXPOSE 4000

@ -18,8 +18,8 @@ build:
$(MAKE) -C server build
$(MAKE) -C client build
rsync -acvh server/bin/posti-dm dist/
rsync -acvh client/dist/ dist/
rsync -ach server/bin/posti-dm dist/
rsync -ach client/dist/ dist/
cp .env dist/.env

@ -5,9 +5,9 @@ all: setup build
.PHONY: setup
setup:
mkdir -p dist/
npm install
.PHONY: build
build:
npm install
npm run build

@ -5,8 +5,6 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Posti DM</title>
<script type="module" src="./src/index.js"></script>
</head>
<body>
<nav>
@ -15,18 +13,21 @@
</div>
<div class="nav-group center">
<div class="nav-item">
<a href=".">Posti DM</a>
<a href="/stanze">Stanze</a>
</div>
<div class="nav-item">
<a href="/orari">Orari</a>
</div>
</div>
<div class="nav-group right">
<div id="login-label" class="nav-item">
<a href="./login.html">Login</a>
<div id="nav-user" class="nav-group right">
<div class="nav-item login-label">
<a href="/login">Login</a>
</div>
<div id="logged-label" class="nav-item hidden">
<div class="nav-item hidden logged-label">
<!-- @username -->
</div>
<div id="logout-label" class="nav-item hidden">
<button id="logout-button">Logout</button>
<div class="nav-item hidden logout-label">
<button class="logout-button">Logout</button>
</div>
</div>
</nav>

@ -5,8 +5,6 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Posti DM</title>
<script type="module" src="./src/index.js"></script>
</head>
<body>
<nav>
@ -15,7 +13,7 @@
</div>
<div class="nav-group center">
<div class="nav-item">
<a href=".">Posti DM</a>
<a href="/">Posti DM</a>
</div>
</div>
</nav>

@ -0,0 +1,178 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Posti DM</title>
<link href="https://unpkg.com/boxicons@2.1.2/css/boxicons.min.css" rel="stylesheet" />
</head>
<body>
<nav>
<div class="nav-group left">
<div class="nav-item" id="clock">14:23</div>
</div>
<div class="nav-group center">
<div class="nav-item">
<a href="/">Posti DM</a>
</div>
<div class="nav-item">
<a href="/stanze">Stanze</a>
</div>
</div>
<div class="nav-group right">
<div id="login-label" class="nav-item">
<a href="/login">Login</a>
</div>
<div id="logged-label" class="nav-item hidden">
<!-- @username -->
</div>
<div id="logout-label" class="nav-item hidden">
<button id="logout-button">Logout</button>
</div>
</div>
</nav>
<main>
<div id="orari-time-table" class="time-table">
<div class="controls">
<div class="current-range">Settimana, 21 Marzo &mdash; 27 Marzo</div>
<div class="group">
<div class="previous">
<button class="square">
<i class="bx bx-chevron-left bx-sm"></i>
</button>
</div>
<div class="today">
<button class="square">
<i class="bx bxs-home"></i>
</button>
</div>
<div class="next">
<button class="square">
<i class="bx bx-chevron-right bx-sm"></i>
</button>
</div>
</div>
</div>
<div class="weekly">
<div class="day">
<div class="title">Lun 21</div>
<div class="slot">
<div class="range">8:30 &ndash; 11:00</div>
<div class="counter">100/100</div>
</div>
<div class="slot">
<div class="range">11:00 &ndash; 13:30</div>
<div class="counter">100/100</div>
</div>
<div class="slot">
<div class="range">13:30 &ndash; 16:00</div>
<div class="counter">100/100</div>
</div>
<div class="slot">
<div class="range">16:00 &ndash; 19:30</div>
<div class="counter">100/100</div>
</div>
</div>
<div class="day">
<div class="title">Mar 21</div>
<div class="slot">
<div class="range">8:30 &ndash; 11:00</div>
<div class="counter">100/100</div>
</div>
<div class="slot">
<div class="range">11:00 &ndash; 13:30</div>
<div class="counter">100/100</div>
</div>
<div class="slot">
<div class="range">13:30 &ndash; 16:00</div>
<div class="counter">100/100</div>
</div>
<div class="slot">
<div class="range">16:00 &ndash; 19:30</div>
<div class="counter">100/100</div>
</div>
</div>
<div class="day">
<div class="title">Mer 21</div>
<div class="slot">
<div class="range">8:30 &ndash; 11:00</div>
<div class="counter">100/100</div>
</div>
<div class="slot">
<div class="range">11:00 &ndash; 13:30</div>
<div class="counter">100/100</div>
</div>
<div class="slot">
<div class="range">13:30 &ndash; 16:00</div>
<div class="counter">100/100</div>
</div>
<div class="slot">
<div class="range">16:00 &ndash; 19:30</div>
<div class="counter">100/100</div>
</div>
</div>
<div class="day">
<div class="title">Gio 21</div>
<div class="slot">
<div class="range">8:30 &ndash; 11:00</div>
<div class="counter">100/100</div>
</div>
<div class="slot">
<div class="range">11:00 &ndash; 13:30</div>
<div class="counter">100/100</div>
</div>
<div class="slot">
<div class="range">13:30 &ndash; 16:00</div>
<div class="counter">100/100</div>
</div>
<div class="slot">
<div class="range">16:00 &ndash; 19:30</div>
<div class="counter">100/100</div>
</div>
</div>
<div class="day">
<div class="title">Ven 21</div>
<div class="slot">
<div class="range">8:30 &ndash; 11:00</div>
<div class="counter">100/100</div>
</div>
<div class="slot">
<div class="range">11:00 &ndash; 13:30</div>
<div class="counter">100/100</div>
</div>
<div class="slot">
<div class="range">13:30 &ndash; 16:00</div>
<div class="counter">100/100</div>
</div>
<div class="slot">
<div class="range">16:00 &ndash; 19:30</div>
<div class="counter">100/100</div>
</div>
</div>
<div class="day">
<div class="title">Sab 21</div>
<div class="slot">
<div class="range">8:30 &ndash; 11:00</div>
<div class="counter">100/100</div>
</div>
<div class="slot">
<div class="range">11:00 &ndash; 13:30</div>
<div class="counter">100/100</div>
</div>
<div class="slot">
<div class="range">13:30 &ndash; 16:00</div>
<div class="counter">100/100</div>
</div>
<div class="slot">
<div class="range">16:00 &ndash; 19:30</div>
<div class="counter">100/100</div>
</div>
</div>
</div>
</div>
</main>
<script type="module" src="./src/pages/orari.js"></script>
</body>
</html>

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Posti DM</title>
<link href="https://unpkg.com/boxicons@2.1.2/css/boxicons.min.css" rel="stylesheet" />
</head>
<body>
<nav>
<div class="nav-group left">
<div class="nav-item" id="clock">14:23</div>
</div>
<div class="nav-group center">
<div class="nav-item">
<a href="/">Posti DM</a>
</div>
<div class="nav-item">
<a href="/stanze">Stanze</a>
</div>
<div class="nav-item">
<a href="/orari">Orari</a>
</div>
</div>
<div class="nav-group right">
<div id="login-label" class="nav-item">
<a href="/login">Login</a>
</div>
<div id="logged-label" class="nav-item hidden">
<!-- @username -->
</div>
<div id="logout-label" class="nav-item hidden">
<button id="logout-button">Logout</button>
</div>
</div>
</nav>
<main>
<p>Prenota</p>
</main>
<script type="module" src="./src/pages/prenota.js"></script>
</body>
</html>

@ -0,0 +1,117 @@
import './style.scss'
/**
* `BASE_URL` string from environment file without final slash (for more readable interpolated strings)
*/
export const BASE_URL = import.meta.env.BASE_URL.replace(/\/$/, '')
//
// User
//
const NotCached = Symbol('not cached')
let userSession = NotCached
export const User = {
async getLogged() {
if (userSession === NotCached) {
console.log('Caching user data...')
const res = await fetch(`${BASE_URL}/api/user`)
const user = await res.json()
userSession = user
}
return userSession
},
async login(username, password) {
try {
const response = await fetch(`${BASE_URL}/api/login`, {
method: 'POST',
headers: {
Accept: 'application/json, text/plain, */*',
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
})
if (!response.ok) {
return { error: await response.text() }
}
// If successful redirect to homepage
location.href = `${BASE_URL}/`
} catch (e) {
return { error: e }
}
},
async logout() {
await fetch(`${BASE_URL}/api/logout`, { method: 'POST' })
location.href = `${BASE_URL}/`
},
}
//
// Room Event Source
//
export function createRoomEventStream(roomId) {
return new EventSource(`${BASE_URL}/api/room_events?id=${roomId}`)
}
//
// Database
//
export const Database = {
async getSeats(roomId) {
const seatList = await (await fetch(`${BASE_URL}/api/room/seats?id=${roomId}`)).json()
const seats = {}
seatList.forEach(seat => {
seats[seat.id] = seat
})
return seats
},
async occupySeat(seatId) {
const response = await fetch(`${BASE_URL}/api/seat/occupy?id=${seatId}`, {
method: 'POST',
})
if (!response.ok) {
throw new Error(await response.text())
}
await response.json()
},
async leaveSeat(seatId) {
const response = await fetch(`${BASE_URL}/api/seat/leave?id=${seatId}`, {
method: 'POST',
})
if (!response.ok) {
throw new Error(await response.text())
}
await response.json()
},
}
//
// Fix page links
//
// TODO: It might actually be better to just create a <page-link> custom element
// Always append BASE_URL to all <a> links in the page (in development mode links should point to html files)
document.querySelectorAll('a').forEach($a => {
const url = $a.getAttribute('href')
let newUrl = BASE_URL + url
if (import.meta.env.MODE === 'development') {
newUrl += newUrl.endsWith('/') ? 'index.html' : '.html'
}
$a.href = newUrl
})

@ -47,5 +47,5 @@ export function createGridLineCanvas($roomGrid) {
const render = () => renderGridLinesCanvas($canvas, [rows, cols])
window.addEventListener('resize', render)
render()
requestAnimationFrame(render)
}

@ -0,0 +1,40 @@
import { User } from '../common.js'
function elements(el) {
return {
elLoggedLabel: el.querySelector('.logged-label'),
elLoginLabel: el.querySelector('.login-label'),
elLogoutLabel: el.querySelector('.logout-label'),
elLogoutButton: el.querySelector('.logout-button'),
}
}
function setup(el) {
const { elLogoutButton } = elements(el)
elLogoutButton.addEventListener('click', () => User.logout())
}
function update(el, { user }) {
const { elLoggedLabel, elLoginLabel, elLogoutLabel } = elements(el)
elLoginLabel.classList.toggle('hidden', user)
const roleString =
user && user.permissions.length > 0 ? ` (${user.permissions.join(', ')})` : ''
elLoggedLabel.innerText = user ? `@${user.id}${roleString}` : ''
elLoggedLabel.classList.toggle('hidden', !user)
elLogoutLabel.classList.toggle('hidden', !user)
}
export function createNavUser(el) {
setup(el)
// Load current user
;(async () => {
const user = await User.getLogged()
update(el, { user })
})()
}

@ -1,8 +1,8 @@
import { BASE_URL, createRoomEventStream, Database, getLoggedUser } from '../index.js'
import { addTooltipElementListener, resetTooltip, setTooltipText } from './tooltip.js'
import { BASE_URL, createRoomEventStream, Database, User } from '../common'
import { addTooltipElementListener, setTooltipText } from './tooltip'
async function renderWidget(elSeatMap, seats) {
const user = await getLoggedUser()
const user = await User.getLogged()
Object.values(seats).forEach(seat => {
const { id, occupiedBy } = seat
@ -26,7 +26,7 @@ async function renderWidget(elSeatMap, seats) {
}
export async function createSeatWidget($roomGrid, roomId) {
const user = await getLoggedUser()
const user = await User.getLogged()
const elSeats = [...$roomGrid.querySelectorAll('[data-seat-id]')]
const elSeatMap = {}

@ -0,0 +1,3 @@
import { BASE_URL, Database } from '../common'
export function createTimeTable(el) {}

@ -1,52 +0,0 @@
import './style.scss'
export const BASE_URL = import.meta.env.BASE_URL.replace(/\/$/, '')
let USER = false
export async function getLoggedUser() {
if (USER === false) {
console.log('Caching user data...')
USER = await (await fetch(`${BASE_URL}/api/user`)).json()
}
return USER
}
export function createRoomEventStream(roomId) {
return new EventSource(`${BASE_URL}/api/room_events?id=${roomId}`)
}
export const Database = {
async getSeats(roomId) {
const seatList = await (await fetch(`${BASE_URL}/api/room/seats?id=${roomId}`)).json()
const seats = {}
seatList.forEach(seat => {
seats[seat.id] = seat
})
return seats
},
async occupySeat(seatId) {
const response = await fetch(`${BASE_URL}/api/seat/occupy?id=${seatId}`, {
method: 'POST',
})
if (!response.ok) {
throw new Error(await response.text())
}
await response.json()
},
async leaveSeat(seatId) {
const response = await fetch(`${BASE_URL}/api/seat/leave?id=${seatId}`, {
method: 'POST',
})
if (!response.ok) {
throw new Error(await response.text())
}
await response.json()
},
}

@ -1,48 +1,30 @@
import { createGridLineCanvas } from '../components/gridlines.js'
import { BASE_URL, getLoggedUser } from '../index.js'
import { createSeatWidget } from '../components/seats-widget.js'
import { createClock } from '../components/clock.js'
import { attachTooltip } from '../components/tooltip.js'
import '../common'
const elClock = document.querySelector('#clock')
import { createGridLineCanvas } from '../components/grid-lines'
import { BASE_URL } from '../common'
import { createSeatWidget } from '../components/seats-widget'
import { createClock } from '../components/clock'
import { attachTooltip } from '../components/tooltip'
import { createNavUser } from '../components/nav-user.js'
const elLoggedLabel = document.querySelector('#logged-label')
const elLoginLabel = document.querySelector('#login-label')
const elLogoutLabel = document.querySelector('#logout-label')
const elClock = document.querySelector('#clock')
const elLogoutButton = document.querySelector('#logout-button')
const elNavUser = document.querySelector('#nav-user')
const elRoomGrid = document.querySelector('.room-grid')
async function logout() {
await fetch(`${BASE_URL}/api/logout`, { method: 'POST' })
location.href = `${BASE_URL}`
}
async function main() {
const urlSearchParams = new URLSearchParams(window.location.search)
const params = Object.fromEntries(urlSearchParams.entries())
console.log(params)
elLogoutButton.addEventListener('click', () => logout())
const user = await getLoggedUser()
if (user) {
elLoginLabel.classList.add('hidden')
elLoggedLabel.innerText =
'@' + user.id + (user.permissions.length > 0 ? ` (${user.permissions.join(', ')})` : '')
elLoggedLabel.classList.remove('hidden')
elLogoutLabel.classList.remove('hidden')
}
// Widgets
// createClock(elClock)
createGridLineCanvas(elRoomGrid)
createSeatWidget(elRoomGrid, 'aula-stud')
createNavUser(elNavUser)
// Use tooltips only on desktop
if (matchMedia('(pointer: fine)').matches) {
attachTooltip()

@ -1,4 +1,6 @@
import { BASE_URL, getLoggedUser } from '../index.js'
import '../common'
import { BASE_URL, User } from '../common'
//
// Page Element
@ -16,8 +18,7 @@ const elLoginButton = document.querySelector('#button-login')
//
let errorTimeoutHandle = null
function displayErrorString(e) {
function displayFormErrorMessage(e) {
if (errorTimeoutHandle) clearTimeout(errorTimeoutHandle)
elErrorString.classList.toggle('hidden', false)
elErrorString.innerText = e.toString()
@ -31,28 +32,10 @@ function displayErrorString(e) {
// Handle Login Button
//
async function login() {
try {
const response = await fetch(`${BASE_URL}/api/login`, {
method: 'POST',
headers: {
Accept: 'application/json, text/plain, */*',
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: elLoginUsernameInput.value,
password: elLoginPasswordInput.value,
}),
})
if (!response.ok) {
displayErrorString(await response.text())
return
}
location.href = `${BASE_URL}/`
} catch (e) {
displayErrorString(e)
async function onLoginButtonClick() {
const res = await User.login(elLoginUsernameInput.value, elLoginPasswordInput.value)
if (res.error) {
displayFormErrorMessage(res.error)
}
}
@ -61,16 +44,17 @@ async function login() {
//
async function main() {
const user = await getLoggedUser()
console.log(user)
const user = await User.getLogged()
if (user) {
location.href = `${BASE_URL}/`
}
elLoginButton.addEventListener('click', () => login())
elLoginButton.addEventListener('click', () => {
onLoginButtonClick()
})
elLoginPasswordInput.addEventListener('keydown', e => {
if (e.key === 'Enter') login()
if (e.key === 'Enter') onLoginButtonClick()
})
elLoginUsernameInput.focus()

@ -0,0 +1,10 @@
import '../common'
import { createTimeTable } from '../components/time-table.js'
const elTimeTable = document.querySelector('#orari-time-table')
async function main() {
createTimeTable(elTimeTable)
}
main()

@ -0,0 +1,7 @@
import '../common.js'
async function main() {
// TODO: ...
}
main()

@ -102,18 +102,37 @@ a:visited {
}
}
@mixin button-panel {
background: #ddd;
color: #222;
box-shadow: 0 2px 12px 2px #0002, 0 0 2px 0px #0005;
&:hover {
background: #d0d0d0;
}
}
button {
@include button-panel;
border: none;
background: #ddd;
padding: 0 0.75rem;
height: 2rem;
border-radius: 3px;
color: #222;
box-shadow: 0 2px 12px 2px #0002, 0 1px 3px 0px #0003;
cursor: pointer;
&:hover {
background: #d0d0d0;
display: flex;
align-items: center;
justify-content: center;
&:active {
background: #c4c4c4;
}
&.square {
padding: 0;
width: 2rem;
height: 2rem;
}
}
@ -197,7 +216,7 @@ main {
width: 100%;
@media screen and (min-width: $media-small-device-size) {
max-width: 75ch;
max-width: 80ch;
}
padding-top: 2rem;
@ -349,6 +368,76 @@ main {
}
}
//
// Time Table
//
.time-table {
@include padded-panel;
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
.controls {
display: flex;
width: 100%;
gap: 1rem;
justify-content: space-between;
.group {
display: flex;
gap: 1rem;
}
align-items: center;
}
.weekly {
display: flex;
width: 100%;
gap: 0.75rem;
.day {
display: flex;
flex-direction: column;
width: 100%;
gap: 0.5rem;
.title {
display: flex;
flex-direction: column;
align-items: center;
font-weight: 600;
}
.slot {
@include button-panel;
width: 100%;
min-height: 5rem;
padding: 0.25rem;
font-size: 13px;
user-select: none;
cursor: pointer;
.counter {
font-size: 18px;
}
}
}
}
}
//
// Tooltip
//
body {
position: relative;

@ -2,17 +2,19 @@ import { defineConfig, loadEnv } from 'vite'
import { resolve } from 'path'
export default defineConfig(({ mode }) => {
// Load environment variables with no prefixes
process.env = { ...process.env, ...loadEnv(mode, process.cwd(), '') }
console.log(`BASE_URL = "${process.env.BASE_URL}"`)
return {
base: process.env.BASE_URL,
css: { preprocessorOptions: { scss: { charset: false } } },
build: {
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
login: resolve(__dirname, 'login.html'),
orari: resolve(__dirname, 'orari.html'),
prenota: resolve(__dirname, 'prenota.html'),
},
},
},

@ -0,0 +1,111 @@
//
// Prelude
//
type Natural = number
type Integer = number
type Rational = [number, number]
type Real = number
type Datetime = string
type Time = string
type Maybe<T> = { present: false } | { present: true; value: T }
//
// PostiDM
//
type UserID = string
type BookingID = string
type SlotID = string
type SeatID = string
type RoomID = string
type UserPermission = 'basic' | 'helper' | 'moderator' | 'admin'
type PostiDM = {
users: Map<UserID, User>
rooms: Map<RoomID, Room>
seats: Map<SeatID, Seat>
slots: Map<SlotID, Slot>
getCurrentWeekSlots(): Slot[]
bookings: Map<BookingID, Booking>
}
// TODO: Tutte le funzioni "getCurrentSomething()" in realtà sono da pensare meglio in modo da poter passare un range temporale o qualcosa del genere
/**
* Un utente loggato con credenziali di ateneo
*/
type User = {
id: UserID
permissions: Set<UserPermission>
getBookings(): Booking[]
getCurrentSeat(): Maybe<Seat>
}
/**
* Una prenotazione di un utente per un certo slot orario
*/
type Booking = {
id: BookingID
timestamp: Datetime
slotID: SlotID
userID: UserID // cioè ok c'è User.bookings però pensando in SQL è sempre meglio avere comunque un id qui (forse)
getSlot(): Slot // giusto per comodità per parlare direttamente dell'oggetto "Slot" relativo ad un "Booking"
getSeat(): Seat // ".getSlot().getSeat()"
}
/**
* Slot rappresenta uno slot orario prenotabile per un certo posto
*/
type Slot = {
id: SlotID
seatID: SeatID
// Magari invece di eliminare uno slot, in quanto ricrearlo potrebbe essere complicato e forse è meglio che i moderatori semplicemente disabilitino uno slot se vogliono non renderlo prenotabile (?)
// disabled: boolean
range: {
from: Datetime
to: Datetime
}
getCurrentBooking(): Maybe<Booking>
}
/**
* Seat rappresenta un posto in dipartimento in una certa stanza
*/
type Seat = {
id: SeatID
diagram: {
x: Natural
y: Natural
width: Natural
height: Natural
}
// TODO: Forse per ora è meglio fare che più utenti possono prenotare lo stesso posto così inizialmente possiamo fare che in ogni stanza c'è solo un posto e la gente può prenotarsi solo "alla stanza" e non al posto specifico, altrimenti facciamo "getCurrentBookedUser(): Maybe<User>"
getCurrentlyBookedUsers(): User[]
getRoom(): Room
}
/**
* Room rappresenta una stanza in dipartimento e contiene dei posti
*/
type Room = {
id: RoomID
name: string
diagram: {
gridRows: Natural
gridCols: Natural
}
getSeats(): Seat[]
getTotalSeatCount(): Natural // ".seatIDs.length"
getCurrentBookedSeatCount(): Natural
}

@ -0,0 +1,83 @@
package main
import (
"fmt"
"log"
"net/http"
"git.phc.dm.unipi.it/aziis98/posti-dm/server/auth"
"git.phc.dm.unipi.it/aziis98/posti-dm/server/db"
"git.phc.dm.unipi.it/aziis98/posti-dm/server/util"
)
// simpleAuthenticator holds an in memory map of session tokens and a reference to the main database interface
type simpleAuthenticator struct {
// sessions is a map from a sessionToken to userID
sessions map[string]string
database db.Database
}
func (service *simpleAuthenticator) CheckUserPassword(userID, password string) error {
if password != "phc" {
return fmt.Errorf(`invalid password`)
}
// FIXME: al momento quando la password è giusta creiamo tutti gli account necessari
err := service.database.CreateUser(&db.User{
ID: userID,
Permissions: make(util.Set[string]),
})
if err != nil {
log.Printf(`got "%v" while trying to log as @%s`, err, userID)
return nil
}
return nil
}
func (service *simpleAuthenticator) UserPermissions(userID string) ([]string, error) {
user, err := service.database.GetUser(userID)
if err != nil {
return nil, err
}
return user.Permissions.Elements(), nil
}
func (service *simpleAuthenticator) SessionTokenFromUser(userID string) (string, error) {
user, err := service.database.GetUser(userID)
if err != nil {
return "", err
}
token := util.RandomHash(20)
service.sessions[token] = user.ID
return token, nil
}
func (service *simpleAuthenticator) UserFromSessionToken(session string) (*db.User, error) {
userID, present := service.sessions[session]
if !present {
return nil, auth.ErrNoUserForSession
}
user, err := service.database.GetUser(userID)
if err != nil {
return nil, err
}
return user, nil
}
func (service *simpleAuthenticator) AuthenticationFailed(err error) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusUnauthorized)
})
}
func (service *simpleAuthenticator) OtherError(err error) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
})
}

@ -13,119 +13,124 @@ var (
ErrNoUserForSession = errors.New(`no user for session token`)
)
var SessionCookieName = "session" // TODO: Make configurable
// AuthMiddlewareConfig configures the middleware to only accept logged users (if "RequireLogged" is true) and with certain permissions (user must have all permissions inside "WithPermissions")
type AuthMiddlewareConfig struct {
// RequireLogged rejects not logged users if true
RequireLogged bool
// WithPermissions is the list of permissions the user should have to pass the middleware
WithPermissions []string
type User[IdType any] interface {
UID() IdType
}
// // Authenticator is the spec of this library
// type Authenticator interface {
// // Login checks user credentials and adds a session cookie to the user if successfull
// Login(w http.ResponseWriter, r *http.Request, userID, password string)
// // Logout clears the user session cookies (by setting the session cookie timeout to 0)
// Logout(w http.ResponseWriter)
// // Middleware is a configurable middleware to authenticate http routes based on logged status and permissions
// Middleware(*AuthMiddlewareConfig) func(http.Handler) http.Handler
// // LoggedMiddleware accepts all logged users without checking for specific permissions
// LoggedMiddleware() func(http.Handler) http.Handler
// // RequestUser returns the userID for this cookie session token
// RequestUser(r *http.Request) (string, error)
// }
// var _ Authenticator = &AuthService{}
// AuthService handles cookies, authentication and authorization of http routes by providing middlewares, logint/logout methods, user sessions and retriving the userID of an authenticated request.
type AuthService struct {
// Authenticator should be used by clients to provide authentication functions and mapping of session tokens to users
type Authenticator[UserID any, U User[UserID]] interface {
// CheckUserPassword is called to login a user and create a corresponding session, see also "SessionTokenFromUser"
CheckUserPassword func(userID string, password string) error
CheckUserPassword(user UserID, password string) error
// GetUserPermissions gets the list of permissions for this user
UserPermissions func(userID string) ([]string, error)
UserPermissions(user UserID) ([]string, error)
// SessionTokenFromUser returns a (new) session token that represents this user
SessionTokenFromUser func(userID string) (string, error)
// UserFromSessionToken returns the corresponing user for this session token or "auth.ErrNoUserForSession"
UserFromSessionToken func(session string) (string, error)
SessionTokenFromUser(user UserID) (string, error)
// UserFromSessionToken returns the corresponding user for this session token or "service.ErrNoUserForSession"
UserFromSessionToken(session string) (*U, error)
// AuthenticationFailed handles failed authentications
AuthenticationFailed func(error) http.Handler
AuthenticationFailed(error) http.Handler
// OtherError handles other errors
OtherError func(error) http.Handler
OtherError(error) http.Handler
}
// MiddlewareConfig configures the middleware to only accept logged users (if "RequireLogged" is true) and with certain permissions (user must have all permissions inside "NeedPermissions")
type MiddlewareConfig struct {
// RequireLogged rejects not logged users if true
RequireLogged bool
// NeedPermissions is the list of permissions the user should have to pass the middleware
NeedPermissions []string
}
// AuthSessionService given an Authenticator provides functions to login and logout users and an http.Handler middleware that accept users based on permissions and login status
type AuthSessionService[UserID any, U User[UserID]] struct {
SessionCookieName string
SessionCookiePath string
SessionCookieDuration time.Duration
Authenticator Authenticator[UserID, U]
}
// NewAuthSessionService creates a new "*AuthSessionService" with a default session cookie name and path
func NewAuthSessionService[UserID any, U User[UserID]](auth Authenticator[UserID, U]) *AuthSessionService[UserID, U] {
oneWeek := 7 * 24 * time.Hour
return &AuthSessionService[UserID, U]{
"session",
"/",
oneWeek,
auth,
}
}
// Login tries to login a user given its id and password
func (auth *AuthService) Login(w http.ResponseWriter, r *http.Request, userID, password string) error {
if err := auth.CheckUserPassword(userID, password); err != nil {
func (service *AuthSessionService[UserID, U]) Login(w http.ResponseWriter, userID UserID, password string) error {
if err := service.Authenticator.CheckUserPassword(userID, password); err != nil {
return err
}
token, err := auth.SessionTokenFromUser(userID)
token, err := service.Authenticator.SessionTokenFromUser(userID)
if err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Path: "/", // TODO: Make configurable
Name: service.SessionCookieName,
Path: service.SessionCookiePath,
Value: token,
Expires: time.Now().Add(7 * 24 * time.Hour), // TODO: Make configurable
Expires: time.Now().Add(service.SessionCookieDuration),
})
return nil
}
// Logout clears the session cookie from a request effectivly logging out the user for future requests
func (auth *AuthService) Logout(w http.ResponseWriter) {
// Logout clears the session cookie from a request effectively logging out the user for future requests
func (service *AuthSessionService[UserID, U]) Logout(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Path: "/",
Name: service.SessionCookieName,
Path: service.SessionCookiePath,
Value: "",
Expires: time.Now(),
})
}
// Middleware checks if the user is logged or not and if the user has all the permissions set in "config.WithPermissions"
func (auth *AuthService) Middleware(config *AuthMiddlewareConfig) func(http.Handler) http.Handler {
// Middleware returns an http middleware that accepts users based on login status and permissions
func (service *AuthSessionService[UserID, U]) Middleware(config *MiddlewareConfig) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(SessionCookieName)
cookie, err := r.Cookie(service.SessionCookieName)
if err == http.ErrNoCookie {
if !config.RequireLogged { // Login not required
next.ServeHTTP(w, r)
return
}
auth.AuthenticationFailed(err).ServeHTTP(w, r)
service.Authenticator.AuthenticationFailed(err).ServeHTTP(w, r)
return
}
if err != nil {
auth.OtherError(err).ServeHTTP(w, r)
service.Authenticator.OtherError(err).ServeHTTP(w, r)
return
}
userID, err := auth.UserFromSessionToken(cookie.Value)
user, err := service.Authenticator.UserFromSessionToken(cookie.Value)
if err == ErrNoUserForSession {
auth.Logout(w)
service.Logout(w)
w.WriteHeader(http.StatusUnauthorized)
return
}
if err != nil {
auth.OtherError(err).ServeHTTP(w, r)
service.Authenticator.OtherError(err).ServeHTTP(w, r)
return
}
if config.RequireLogged {
userPerms, err := auth.UserPermissions(userID)
userPerms, err := service.Authenticator.UserPermissions((*user).UID())
if err != nil {
auth.OtherError(err).ServeHTTP(w, r)
service.Authenticator.OtherError(err).ServeHTTP(w, r)
return
}
@ -136,7 +141,7 @@ func (auth *AuthService) Middleware(config *AuthMiddlewareConfig) func(http.Hand
// check the user has all the permissions to access the route
hasAll := true
for _, perm := range config.WithPermissions {
for _, perm := range config.NeedPermissions {
if _, present := userPermsMap[perm]; !present {
hasAll = false
break
@ -144,14 +149,14 @@ func (auth *AuthService) Middleware(config *AuthMiddlewareConfig) func(http.Hand
}
if !hasAll {
auth.AuthenticationFailed(err).ServeHTTP(w, r)
service.Authenticator.AuthenticationFailed(err).ServeHTTP(w, r)
return
}
}
// Refresh session cookie expiration
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Name: service.SessionCookieName,
Path: "/",
Value: cookie.Value,
Expires: time.Now().Add(7 * 24 * time.Hour),
@ -166,30 +171,25 @@ func (auth *AuthService) Middleware(config *AuthMiddlewareConfig) func(http.Hand
//
// Middleware(*AuthMiddlewareConfig)
//
// that checks if a user is logged, no extra permissions are checked
func (auth *AuthService) LoggedMiddleware() func(http.Handler) http.Handler {
return auth.Middleware(&AuthMiddlewareConfig{
// that only accepts logged in users, no special permissions are checked
func (service *AuthSessionService[UserID, U]) LoggedMiddleware() func(http.Handler) http.Handler {
return service.Middleware(&MiddlewareConfig{
RequireLogged: true,
WithPermissions: []string{},
NeedPermissions: []string{},
})
}
// RequestUser retrives the "userID" from the given request based on the cookie session token.
// When generics arrive this will become something like
//
// func (auth *AuthService[U]) RequestUser(r *http.Request) *U
//
// and will just return nil if no user is present.
func (auth *AuthService) RequestUser(r *http.Request) (string, error) {
cookie, err := r.Cookie(SessionCookieName)
// RequestUser retrieves the "userID" from the given request based on the cookie session token.
func (service *AuthSessionService[UserID, U]) RequestUser(r *http.Request) (*U, error) {
cookie, err := r.Cookie(service.SessionCookieName)
if err != nil {
return "", err
return nil, err
}
userID, err := auth.UserFromSessionToken(cookie.Value)
user, err := service.Authenticator.UserFromSessionToken(cookie.Value)
if err == ErrNoUserForSession {
return "", err
return nil, err
}
return userID, nil
return user, nil
}

@ -32,12 +32,16 @@ const (
// User represents a user in the database.
type User struct {
ID string `json:"id"`
Permissions util.StringSet `json:"permissions"`
ID string `json:"id"`
Permissions util.Set[string] `json:"permissions"`
// passwordSaltHash string
}
func (user User) UID() string {
return user.ID
}
// Room represents a room in the database, a room has an id, a name and a collection of seatIDs of this room.
type Room struct {
ID string `json:"id"`
@ -96,8 +100,8 @@ type Seat struct {
// Database Interfaces
//
// Store is the main interface for interacting with database implementations, for now the only implementation is "memDB".
type Store interface {
// Database is the main interface for interacting with database implementations, for now the only implementation is "memDB".
type Database interface {
BeginTransaction()
EndTransaction()
@ -118,7 +122,7 @@ type Store interface {
GetRoomOccupiedSeats(roomID string) ([]string, error)
GetRoomFreeSeats(roomID string) ([]string, error)
GetUserSeats(userID string) (util.StringSet, error)
GetUserSeats(userID string) (util.Set[string], error)
OccupySeat(seatID, userID string) error
FreeSeat(seatID string) error
@ -127,7 +131,7 @@ type Store interface {
// TODO: Create an SQLite implementation
// NewInMemoryStore creates an instance of memDB hidden behind the Store interface.
func NewInMemoryStore() Store {
func NewInMemoryStore() Database {
db := &memDB{
&sync.Mutex{},
@ -157,12 +161,12 @@ func NewInMemoryStore() Store {
db.users["aziis98"] = &User{
ID: "aziis98",
Permissions: util.NewStringSet(PermissionAdmin),
Permissions: util.NewSet(PermissionAdmin),
}
db.users["bachoseven"] = &User{
ID: "bachoseven",
Permissions: util.NewStringSet(PermissionAdmin),
Permissions: util.NewSet(PermissionAdmin),
}
return db

@ -146,14 +146,14 @@ func (db *memDB) GetRoomFreeSeats(roomID string) ([]string, error) {
return seats, nil
}
func (db *memDB) GetUserSeats(userID string) (util.StringSet, error) {
func (db *memDB) GetUserSeats(userID string) (util.Set[string], error) {
for _, seat := range db.seats {
if len(seat.OccupiedBy) > 0 && seat.OccupiedBy[0] == userID {
return util.NewStringSet(seat.ID), nil
return util.NewSet(seat.ID), nil
}
}
return util.NewStringSet(), nil
return util.NewSet[string](), nil
}
func (db *memDB) OccupySeat(seatID, userID string) error {

@ -2,88 +2,35 @@ package main
import (
"fmt"
"log"
"net/http"
"git.phc.dm.unipi.it/aziis98/posti-dm/server/auth"
"git.phc.dm.unipi.it/aziis98/posti-dm/server/db"
"git.phc.dm.unipi.it/aziis98/posti-dm/server/httputil"
"git.phc.dm.unipi.it/aziis98/posti-dm/server/httputil/serverevents"
"git.phc.dm.unipi.it/aziis98/posti-dm/server/util"
"github.com/go-chi/chi/v5"
)
type Server struct {
authService *auth.AuthService
auth *auth.AuthSessionService[string, db.User]
roomServerEventStreams map[string]serverevents.Handler
Database db.Store
Database db.Database
ApiRoute chi.Router
}
func NewServer() *Server {
sessions := make(map[string]*db.User)
database := db.NewInMemoryStore()
server := &Server{
Database: database,
authService: &auth.AuthService{
CheckUserPassword: func(userID, password string) error {
if password != "phc" {
return fmt.Errorf(`invalid password`)
}
// FIXME: al momento quando la password è giusta creiamo tutti gli account necessari
err := database.CreateUser(&db.User{
ID: userID,
Permissions: make(util.StringSet),
})
if err != nil {
log.Printf(`got "%v" while trying to log as @%s`, err, userID)
return nil
}
return nil
},
UserPermissions: func(userID string) ([]string, error) {
user, err := database.GetUser(userID)
if err != nil {
return nil, err
}
return user.Permissions.Elements(), nil
auth: auth.NewAuthSessionService[string, db.User](
&simpleAuthenticator{
make(map[string]string),
database,
},
SessionTokenFromUser: func(userID string) (string, error) {
user, err := database.GetUser(userID)
if err != nil {
return "", err
}
token := util.RandomHash(20)
sessions[token] = user
return token, nil
},
UserFromSessionToken: func(session string) (string, error) {
user, present := sessions[session]
if !present {
return "", auth.ErrNoUserForSession
}
return user.ID, nil
},
AuthenticationFailed: func(err error) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusUnauthorized)
})
},
OtherError: func(err error) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
})
},
},
),
roomServerEventStreams: make(map[string]serverevents.Handler),
@ -103,7 +50,7 @@ func NewServer() *Server {
func (server *Server) setupRoutes() {
api := server.ApiRoute
database := server.Database
auth := server.authService
auth := server.auth
// FIXME: in realtà tutte le routes che interagiscono con il db dovrebbero essere in transazione con
// database.BeginTransaction()
@ -122,7 +69,7 @@ func (server *Server) setupRoutes() {
return
}
err := auth.Login(w, r, credentials.Username, credentials.Password)
err := auth.Login(w, credentials.Username, credentials.Password)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
@ -138,17 +85,12 @@ func (server *Server) setupRoutes() {
})
api.Get("/user", func(w http.ResponseWriter, r *http.Request) {
userID, err := auth.RequestUser(r)
user, err := auth.RequestUser(r)
if err != nil {
httputil.WriteJSON(w, nil)
return
}
user, err := database.GetUser(userID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
}
httputil.WriteJSON(w, user)
})
@ -188,38 +130,40 @@ func (server *Server) setupRoutes() {
server.roomServerEventStreams[roomID[0]].ServeHTTP(w, r)
})
requestSeat := func(r *http.Request) (*db.Seat, error) {
seatID, ok := r.URL.Query()["id"]
if !ok {
return nil, fmt.Errorf(`missing seat id`)
}
seat, err := database.GetSeat(seatID[0])
if err != nil {
return nil, err
}
return seat, nil
}
api.With(auth.LoggedMiddleware()).
Post("/seat/occupy", func(w http.ResponseWriter, r *http.Request) {
database.BeginTransaction()
defer database.EndTransaction()
userID, err := auth.RequestUser(r)
user, err := auth.RequestUser(r)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
user, err := database.GetUser(userID)
seat, err := requestSeat(r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
seatID, ok := r.URL.Query()["id"]
if !ok {
http.Error(w, `missing seat id`, http.StatusBadRequest)
return
}
seat, err := database.GetSeat(seatID[0])
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// a seat can be occupied only if empty; simple users can occupy a seat only if they have occupied no other seat; admins and moderator (for now) can occupy even more than one seat (as occupied by an anonymous?) to fix the free seat count of the room
if len(seat.OccupiedBy) == 0 {
userSeats, err := database.GetUserSeats(userID)
userSeats, err := database.GetUserSeats(user.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@ -230,7 +174,7 @@ func (server *Server) setupRoutes() {
return
}
if err := database.OccupySeat(seatID[0], userID); err != nil {
if err := database.OccupySeat(seat.ID, user.ID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@ -244,34 +188,22 @@ func (server *Server) setupRoutes() {
api.With(auth.LoggedMiddleware()).
Post("/seat/leave", func(w http.ResponseWriter, r *http.Request) {
userID, err := auth.RequestUser(r)
user, err := auth.RequestUser(r)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
user, err := database.GetUser(userID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
seatID, ok := r.URL.Query()["id"]
if !ok {
http.Error(w, `missing seat id`, http.StatusBadRequest)
return
}
seat, err := database.GetSeat(seatID[0])
seat, err := requestSeat(r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Check permissions, if the place is occupied then only its owner, a moderator or an admin can clear it
if len(seat.OccupiedBy) > 0 {
if user.ID == seat.OccupiedBy[0] || user.Permissions.HasAny(db.PermissionAdmin, db.PermissionModerator) {
if err := database.FreeSeat(seatID[0]); err != nil {
if err := database.FreeSeat(seat.ID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

@ -4,12 +4,10 @@ import "encoding/json"
type present struct{}
// Waiting for Go 18...
// type Set[T comparable] map[T]present
type StringSet map[string]present
type Set[T comparable] map[T]present
func NewStringSet(elements ...string) StringSet {
set := StringSet{}
func NewSet[T comparable](elements ...T) Set[T] {
set := Set[T]{}
for _, elem := range elements {
set.Add(elem)
@ -18,12 +16,12 @@ func NewStringSet(elements ...string) StringSet {
return set
}
func (set StringSet) Has(value string) bool {
func (set Set[T]) Has(value T) bool {
_, present := set[value]
return present
}
func (set StringSet) HasAny(values ...string) bool {
func (set Set[T]) HasAny(values ...T) bool {
for _, value := range values {
if _, present := set[value]; present {
return true
@ -32,7 +30,7 @@ func (set StringSet) HasAny(values ...string) bool {
return false
}
func (set StringSet) Contains(other StringSet) bool {
func (set Set[T]) Contains(other Set[T]) bool {
for v := range other {
if !set.Has(v) {
return false
@ -41,16 +39,16 @@ func (set StringSet) Contains(other StringSet) bool {
return true
}
func (set StringSet) Add(value string) {
func (set Set[T]) Add(value T) {
set[value] = present{}
}
func (set StringSet) Remove(value string) {
func (set Set[T]) Remove(value T) {
delete(set, value)
}
func (set StringSet) Elements() []string {
elements := []string{}
func (set Set[T]) Elements() []T {
elements := []T{}
for elem := range set {
elements = append(elements, elem)
@ -59,12 +57,12 @@ func (set StringSet) Elements() []string {
return elements
}
func (set StringSet) MarshalJSON() ([]byte, error) {
func (set Set[T]) MarshalJSON() ([]byte, error) {
return json.Marshal(set.Elements())
}
func (set StringSet) UnmarshalJSON(data []byte) error {
elements := []string{}
func (set Set[T]) UnmarshalJSON(data []byte) error {
elements := []T{}
if err := json.Unmarshal(data, &elements); err != nil {
return err
}

Loading…
Cancel
Save