diff --git a/client/index.html b/client/index.html index 0e6f9be..947b9f8 100644 --- a/client/index.html +++ b/client/index.html @@ -9,77 +9,135 @@ -
-
+
-
AulaStud
-
-
+

Aula Studenti

+ +
+ +
+ +

+ Clicca su un posto per occuparlo (i posti liberi sono + grigi, quelli + rossi + sono occupati dagli altri, mentre il tuo è in + verde) +

+
+
+ +
Some text
+ + + diff --git a/client/login.html b/client/login.html new file mode 100644 index 0000000..9c6c620 --- /dev/null +++ b/client/login.html @@ -0,0 +1,37 @@ + + + + + + + Posti DM + + + + + +
+
+

Login

+ + + + + + + + +
+
+ + + diff --git a/client/src/components/clock.js b/client/src/components/clock.js new file mode 100644 index 0000000..d666467 --- /dev/null +++ b/client/src/components/clock.js @@ -0,0 +1,13 @@ +export function createClock(elClock) { + const renderClock = () => { + const pad = s => (s + '').padStart(2, '0') + const now = new Date(), + hours = now.getHours(), + minutes = now.getMinutes(), + seconds = now.getSeconds() + elClock.innerText = `${pad(hours)}:${pad(minutes)}:${pad(seconds)}` + } + + setInterval(renderClock, 1000) + renderClock() +} diff --git a/client/src/components/gridlines.js b/client/src/components/gridlines.js new file mode 100644 index 0000000..e741c90 --- /dev/null +++ b/client/src/components/gridlines.js @@ -0,0 +1,51 @@ +function renderGridLinesCanvas($canvas, [rows, cols]) { + $canvas.width = $canvas.offsetWidth * 2 + $canvas.height = $canvas.offsetHeight * 2 + + const g = $canvas.getContext('2d') + const width = $canvas.width / 2 + const height = $canvas.height / 2 + g.scale(2, 2) + + g.strokeStyle = '#ddd' + g.lineWidth = 2 + g.setLineDash([4, 4]) + + for (let i = 0; i < cols + 1; i++) { + const x = 2 + ((width - 4) / cols) * i + g.beginPath() + g.moveTo(x, 0) + g.lineTo(x, height) + g.stroke() + } + + for (let j = 0; j < rows + 1; j++) { + const y = 2 + ((height - 4) / rows) * j + g.beginPath() + g.moveTo(0, y) + g.lineTo(width, y) + g.stroke() + } +} + +export function createGridLineCanvas($roomGrid) { + const $canvas = document.createElement('canvas') + $roomGrid.append($canvas) + + $canvas.style.position = 'absolute' + $canvas.style.inset = '0' + $canvas.style.width = '100%' + $canvas.style.height = '100%' + $canvas.style.zIndex = '-1' + + const [rows, cols] = getComputedStyle($roomGrid) + .getPropertyValue('aspect-ratio') + .split('/') + .reverse() + .map(s => parseInt(s)) + + const render = () => renderGridLinesCanvas($canvas, [rows, cols]) + + window.addEventListener('resize', render) + render() +} diff --git a/client/src/components/seats-widget.js b/client/src/components/seats-widget.js new file mode 100644 index 0000000..24b3739 --- /dev/null +++ b/client/src/components/seats-widget.js @@ -0,0 +1,100 @@ +import { createRoomEventStream, Database, getLoggedUser } from '../index.js' +import { addTooltipElementListener, resetTooltip, setTooltipText } from './tooltip.js' + +async function renderWidget(elSeatMap, seats) { + const user = await getLoggedUser() + + Object.values(seats).forEach(seat => { + const { id, occupiedBy } = seat + + elSeatMap[id].classList.remove('libero') + elSeatMap[id].classList.remove('occupato') + elSeatMap[id].classList.remove('mio') + + if (occupiedBy.length > 0) { + const [seatOccupant] = occupiedBy + + if (user && seatOccupant === user.id) { + elSeatMap[id].classList.add('mio') + } else { + elSeatMap[id].classList.add('occupato') + } + } else { + elSeatMap[id].classList.add('libero') + } + }) +} + +export async function createSeatWidget($roomGrid, roomId) { + const user = await getLoggedUser() + + const elSeats = [...$roomGrid.querySelectorAll('[data-seat-id]')] + const elSeatMap = {} + + elSeats.forEach($seat => { + const seatId = $seat.dataset.seatId + $seat.dataset.index = seatId.split('-')[2] + + elSeatMap[seatId] = $seat + }) + + // Full widget state + let seats = await Database.getSeats(roomId) + + // First render + renderWidget(elSeatMap, seats) + + // Setup event listeners + Object.entries(elSeatMap).forEach(([seatId, $seat]) => { + addTooltipElementListener($hovering => { + if ($seat.contains($hovering)) { + const occupiedBy = seats[seatId].occupiedBy + + if (occupiedBy.length > 0) { + if (user && occupiedBy[0] === user.id) { + setTooltipText(`Questo è il tuo posto!`) + } else { + setTooltipText(`Occupato da @${occupiedBy[0]}`) + } + } else { + setTooltipText('Libero') + } + + return true + } else { + resetTooltip() + } + }) + + $seat.addEventListener('click', () => { + if (!user) { + location.href = '/login.html' + return + } + + const occupiedBy = seats[seatId].occupiedBy + + if (occupiedBy.length === 0) { + const confirmResponse = confirm('Occupare il posto?') + if (confirmResponse) { + Database.occupySeat(seatId) + } + } else if (occupiedBy.length === 1 && occupiedBy[0] === user.id) { + const answer = confirm('Lasciare veramente il posto?') + if (answer) { + Database.leaveSeat(seatId) + } + } else { + alert('Posto già occupato!') + } + }) + }) + + // Refresh room diagram on message from server + createRoomEventStream(roomId).addEventListener('message', async e => { + console.log(e.data) + + seats = await Database.getSeats(roomId) + renderWidget(elSeatMap, seats) + }) +} diff --git a/client/src/components/tooltip.js b/client/src/components/tooltip.js new file mode 100644 index 0000000..8aa5629 --- /dev/null +++ b/client/src/components/tooltip.js @@ -0,0 +1,32 @@ +const LISTENERS = [] +const $tooltip = document.querySelector('#tooltip') + +export function attachTooltip() { + document.body.addEventListener('mousemove', e => { + $tooltip.style.setProperty('--x', 10 + e.pageX + 'px') + $tooltip.style.setProperty('--y', 10 + e.pageY + 'px') + + LISTENERS.find(listener => listener(e.target)) + }) +} + +export function addTooltipElementListener(callbackFn) { + LISTENERS.push(callbackFn) +} + +export function setTooltip(mountFn) { + $tooltip.classList.remove('hidden') + mountFn($tooltip) +} + +export function setTooltipText(text) { + $tooltip.classList.remove('hidden') + setTooltip($t => { + $t.innerText = text + }) +} + +export function resetTooltip() { + $tooltip.classList.add('hidden') + $tooltip.innerHTML = '' +} diff --git a/client/src/index.js b/client/src/index.js index 72dcf9c..a4f809c 100644 --- a/client/src/index.js +++ b/client/src/index.js @@ -1 +1,34 @@ import './style.scss' + +let USER = false +export async function getLoggedUser() { + if (USER === false) { + console.log('Caching user data...') + USER = await (await fetch('/api/user')).json() + } + + return USER +} + +export function createRoomEventStream(roomId) { + return new EventSource(`/api/room_events?id=${roomId}`) +} + +export const Database = { + async getSeats(roomId) { + const seatList = await (await fetch(`/api/room/seats?id=${roomId}`)).json() + const seats = {} + + seatList.forEach(seat => { + seats[seat.id] = seat + }) + + return seats + }, + async occupySeat(seatId) { + await (await fetch(`/api/seat/occupy?id=${seatId}`, { method: 'POST' })).json() + }, + async leaveSeat(seatId) { + await (await fetch(`/api/seat/leave?id=${seatId}`, { method: 'POST' })).json() + }, +} diff --git a/client/src/pages/index.js b/client/src/pages/index.js new file mode 100644 index 0000000..54809c5 --- /dev/null +++ b/client/src/pages/index.js @@ -0,0 +1,52 @@ +import { createGridLineCanvas } from '../components/gridlines.js' +import { getLoggedUser } from '../index.js' +import { createSeatWidget } from '../components/seats-widget.js' +import { createClock } from '../components/clock.js' +import { attachTooltip } from '../components/tooltip.js' + +const elClock = document.querySelector('#clock') + +const elLoggedLabel = document.querySelector('#logged-label') +const elLoginLabel = document.querySelector('#login-label') +const elLogoutLabel = document.querySelector('#logout-label') + +const elLogoutButton = document.querySelector('#logout-button') + +const elRoomGrid = document.querySelector('.room-grid') + +async function logout() { + await fetch('/api/logout', { method: 'POST' }) + location.href = '/' +} + +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') + + // Use tooltips only on desktop + if (matchMedia('(pointer: fine)').matches) { + attachTooltip() + } +} + +main() diff --git a/client/src/pages/login.js b/client/src/pages/login.js new file mode 100644 index 0000000..56d49dd --- /dev/null +++ b/client/src/pages/login.js @@ -0,0 +1,79 @@ +import { getLoggedUser } from '../index.js' + +// +// Page Element +// + +const elErrorString = document.querySelector('#error-string') + +const elLoginUsernameInput = document.querySelector('#input-login-username') +const elLoginPasswordInput = document.querySelector('#input-login-password') + +const elLoginButton = document.querySelector('#button-login') + +// +// Display error message +// + +let errorTimeoutHandle = null + +function displayErrorString(e) { + if (errorTimeoutHandle) clearTimeout(errorTimeoutHandle) + elErrorString.classList.toggle('hidden', false) + elErrorString.innerText = e.toString() + errorTimeoutHandle = setTimeout(() => { + elErrorString.classList.toggle('hidden', true) + elErrorString.innerText = '' + }, 5 * 1000) +} + +// +// Handle Login Button +// + +async function login() { + try { + const response = await fetch('/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 = '/' + } catch (e) { + displayErrorString(e) + } +} + +// +// Main +// + +async function main() { + const user = await getLoggedUser() + console.log(user) + if (user) { + location.href = '/' + } + + elLoginButton.addEventListener('click', () => login()) + + elLoginPasswordInput.addEventListener('keydown', e => { + if (e.key === 'Enter') login() + }) + + elLoginUsernameInput.focus() +} + +main() diff --git a/client/src/reset.scss b/client/src/reset.scss index 2a8b510..3c7a176 100644 --- a/client/src/reset.scss +++ b/client/src/reset.scss @@ -35,7 +35,6 @@ html:focus-within { // Set core body defaults body { min-height: 100vh; - text-rendering: optimizeSpeed; line-height: 1.5; } diff --git a/client/src/style.scss b/client/src/style.scss index c5abf61..25faeca 100644 --- a/client/src/style.scss +++ b/client/src/style.scss @@ -1,249 +1,497 @@ @use './reset.scss'; +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600&display=swap'); + +$media-small-device-size: 750px; body { - background: #f0f0f0; + color: #333; + background: #f8f8f8; + // background: #fff; + font-family: 'Inter', sans-serif; display: flex; flex-direction: column; align-items: center; -} -header { - width: 100%; - height: 3rem; + padding-bottom: 6rem; +} - position: relative; +// +// Utility Classes +// +@mixin panel { background: #fff; - border-bottom: 1px solid #ddd; + box-shadow: 0 0 8px 0px #0001, 0 0 16px 8px #0001; - .nav-item { - height: 3rem; + border-radius: 0.25rem; - display: flex; - align-items: center; - justify-content: center; + @media screen and (max-width: $media-small-device-size) { + width: 100%; + max-width: 100%; + border-radius: 0; } +} - .left { - position: absolute; - left: 0; +@mixin padded-panel { + @include panel; - padding-left: 1rem; + padding: 2rem; - display: flex; - gap: 1rem; + @media screen and (max-width: $media-small-device-size) { + padding: 1rem; } +} - .center { - position: absolute; - left: 50%; - transform: translate(-50%, 0); +.hidden { + display: none !important; +} - display: flex; - gap: 1rem; +// +// Typography +// + +// Headings + +$base-font-size: 18px; +$heading-scale: 1.33; + +@function pow($number, $exponent) { + $value: 1; + + @if $exponent > 0 { + @for $i from 1 through $exponent { + $value: $value * $number; + } } - .right { - position: absolute; - right: 0; + @return $value; +} - padding-right: 1rem; +@for $i from 1 through 5 { + h#{$i} { + font-family: 'Inter', serif; - display: flex; - gap: 1rem; + $factor: pow($heading-scale, 5 - $i); + font-size: $base-font-size * $factor; + font-weight: 600; } } -.panel { - border: 1px solid #ddd; - background: #fff; +// +// Base Elements +// + +a, +a:visited { + color: royalblue; + text-decoration: none; + + &:hover { + text-decoration: underline; + } } -main { - display: flex; - flex-direction: column; - gap: 2rem; +// +// General Page Structure +// - align-items: center; +nav { + width: 100%; + height: 4rem; + position: relative; - padding: 2rem 1rem 2rem; + @include panel; + border-radius: 0; + + .nav-group, + .nav-item { + height: 100%; - .room-name { display: flex; align-items: center; justify-content: center; + } - font-size: 26px; - font-weight: bold; + .left, + .center, + .right { + display: flex; + gap: 1rem; } - .room-main { - display: grid; + @media screen and (max-width: $media-small-device-size) { + height: unset; + display: flex; + flex-direction: column; - grid-template-columns: auto 1fr; - grid-template-rows: auto auto; - grid-template-areas: - 'diagram bookings' - 'timeline timeline'; + .nav-group { + height: 4rem; + } + } - max-width: 800px; + @media screen and (min-width: $media-small-device-size) { + .left, + .center, + .right { + position: absolute; + } - .room-diagram { - grid-area: diagram; + .left { + left: 0; + padding-left: 1rem; } - .room-bookings { - grid-area: bookings; + + .center { + left: 50%; + transform: translate(-50%, 0); + + font-size: 22px; } - .room-timeline { - grid-area: timeline; + + .right { + right: 0; + padding-right: 1rem; } + } +} - gap: 1rem; +main { + width: 100%; - .room-diagram { - padding: 1rem; + @media screen and (min-width: $media-small-device-size) { + max-width: 75ch; + } - // --posto-libero-bg: #ddf; - // --posto-libero-bg-1: #88f; - // --posto-libero-bg-2: #66d; - --posto-libero-bg: #8d8; - --posto-occupato-bg: #d88; + padding-top: 2rem; - display: grid; - grid-template-columns: repeat(15, 1fr); - grid-template-rows: repeat(16, 1fr); - - gap: 0.25rem; - - aspect-ratio: 15 / 16; - - width: calc(15 * 2rem); - max-width: 500px; - - .posto { - display: flex; - align-items: center; - justify-content: center; - flex-direction: column; - - &.libero { - background: var(--posto-libero-bg); - } - &.occupato { - background: var(--posto-occupato-bg); - } - - border: 2px solid #0004; - cursor: pointer; - - &:hover { - border-color: #0006; - border-width: 4px; - } - - line-height: 1; - - &::before { - font-size: 12px; - content: 'Posto'; - } - &::after { - font-size: 12px; - content: attr(data-index); - } - } - } + display: flex; + flex-direction: column; + align-items: center; - .room-bookings { - display: flex; - flex-direction: column; + gap: 2rem; +} - min-width: 15rem; - width: 100%; +.form { + @include padded-panel; - padding: 0.75rem 0; + display: grid; + grid-template-columns: auto 1fr; - .seat-list { - display: flex; - flex-direction: column; + gap: 0.5rem 1rem; - .seat { - padding: 0.25rem 1rem; + .error { + color: #d00; + } - display: grid; - grid-template-columns: 1fr auto; + .fill-row { + grid-column: 1 / span 2; + place-self: center; + } +} - height: 2.5rem; +.room-diagram { + --posto-libero-bg: #ddd; + --posto-occupato-bg: #d88; + --posto-mio-occupato-bg: #8d8; - &:hover { - background: #0001; - } + @include padded-panel; - .name { - display: flex; - align-items: center; - } + display: flex; + flex-direction: column; + align-items: center; - &.selected { - background: #0002; + gap: 1rem; - .name { - font-style: italic; - } - } - } - } - } + @media screen and (min-width: $media-small-device-size) { + width: 450px; + } - .room-timeline { - padding: 1rem; + .room-grid { + // To absolutly position a canvas underneat and render grid lines + position: relative; + z-index: 0; - display: flex; - flex-direction: row; - gap: 0.5rem; + display: grid; + grid-template-columns: repeat(15, 1fr); + grid-template-rows: repeat(16, 1fr); + gap: 4px; + padding: 4px; + + aspect-ratio: 15 / 16; + + width: 100%; + + .posto { + display: flex; align-items: center; + justify-content: center; + flex-direction: column; + + &.mio { + background: var(--posto-mio-occupato-bg); + } + &.libero { + background: var(--posto-libero-bg); + } + &.occupato { + background: var(--posto-occupato-bg); + } + + border: 4px solid #0004; + cursor: pointer; + + &:hover { + border-color: #0008; + border-width: 4px; + } - overflow-x: auto; + line-height: 1; - button { - min-width: 8rem; - height: 3rem; + // @media screen and (min-width: $media-small-device-size) { + &::before { + font-size: 12px; + content: 'Posto'; } + &::after { + font-size: 12px; + content: attr(data-index); + } + // } } } -} - -@media screen and (max-width: 1000px) { - header { - height: unset; + .room-seat-list { display: flex; flex-direction: column; + gap: 0.5rem; - .nav-cell { - position: relative; - padding: 0 1rem; + width: 100%; + + .seat-item { + display: grid; + grid-template-columns: 1fr auto; } } +} - main { - .room-main { - display: flex; - flex-direction: column; +body { + position: relative; - .room-diagram { - width: 200px; - } + #tooltip { + position: absolute; + left: 0; + right: 0; + padding: 0.25rem; - .room-timeline { - flex-direction: column; - } - } + width: fit-content; + + font-size: 14px; + line-height: 1; + + opacity: 0.8; + + background: #666; + color: #ddd; + + transform: translate(var(--x, -100px), var(--y, -100px)); + + z-index: 100; + user-select: none; + pointer-events: none; } } +// .panel { +// border: 1px solid #ddd; +// background: #fff; +// } + +// main { +// display: flex; +// flex-direction: column; +// gap: 2rem; + +// align-items: center; + +// padding: 2rem 1rem 2rem; + +// .room-name { +// display: flex; +// align-items: center; +// justify-content: center; + +// font-size: 26px; +// font-weight: bold; +// } + +// .room-main { +// display: grid; + +// grid-template-columns: auto 1fr; +// grid-template-rows: auto auto; +// grid-template-areas: +// 'diagram bookings' +// 'timeline timeline'; + +// max-width: 800px; + +// .room-diagram { +// grid-area: diagram; +// } +// .room-bookings { +// grid-area: bookings; +// } +// .room-timeline { +// grid-area: timeline; +// } + +// gap: 1rem; + +// .room-diagram { +// padding: 1rem; + +// // --posto-libero-bg: #ddf; +// // --posto-libero-bg-1: #88f; +// // --posto-libero-bg-2: #66d; +// --posto-libero-bg: #8d8; +// --posto-occupato-bg: #d88; + +// display: grid; +// grid-template-columns: repeat(15, 1fr); +// grid-template-rows: repeat(16, 1fr); + +// gap: 0.25rem; + +// aspect-ratio: 15 / 16; + +// width: calc(15 * 2rem); +// max-width: 500px; + +// .posto { +// display: flex; +// align-items: center; +// justify-content: center; +// flex-direction: column; + +// &.libero { +// background: var(--posto-libero-bg); +// } +// &.occupato { +// background: var(--posto-occupato-bg); +// } + +// border: 2px solid #0004; +// cursor: pointer; + +// &:hover { +// border-color: #0006; +// border-width: 4px; +// } + +// line-height: 1; + +// &::before { +// font-size: 12px; +// content: 'Posto'; +// } +// &::after { +// font-size: 12px; +// content: attr(data-index); +// } +// } +// } + +// .room-bookings { +// display: flex; +// flex-direction: column; + +// min-width: 15rem; +// width: 100%; + +// padding: 0.75rem 0; + +// .seat-list { +// display: flex; +// flex-direction: column; + +// .seat { +// padding: 0.25rem 1rem; + +// display: grid; +// grid-template-columns: 1fr auto; + +// height: 2.5rem; + +// &:hover { +// background: #0001; +// } + +// .name { +// display: flex; +// align-items: center; +// } + +// &.selected { +// background: #0002; + +// .name { +// font-style: italic; +// } +// } +// } +// } +// } + +// .room-timeline { +// padding: 1rem; + +// display: flex; +// flex-direction: row; +// gap: 0.5rem; + +// align-items: center; + +// overflow-x: auto; + +// button { +// min-width: 8rem; +// height: 3rem; +// } +// } +// } +// } + +// @media screen and (max-width: $media-small-device-size) { +// header { +// height: unset; + +// display: flex; +// flex-direction: column; + +// .nav-cell { +// position: relative; +// padding: 0 1rem; +// } +// } + +// main { +// .room-main { +// display: flex; +// flex-direction: column; + +// .room-diagram { +// width: 200px; +// } + +// .room-timeline { +// flex-direction: column; +// } +// } +// } +// } + img { transform: rotate(180deg); } diff --git a/server/auth/auth.go b/server/auth/auth.go index 2b3a46f..0af2fe5 100644 --- a/server/auth/auth.go +++ b/server/auth/auth.go @@ -49,7 +49,7 @@ type AuthService struct { UserFromSessionToken func(session string) (string, error) // AuthenticationFailed handles failed authentications - AuthenticationFailed http.Handler + AuthenticationFailed func(error) http.Handler // OtherError handles other errors OtherError func(error) http.Handler @@ -57,7 +57,7 @@ type AuthService struct { func (auth *AuthService) Login(w http.ResponseWriter, r *http.Request, userID, password string) { if err := auth.CheckUserPassword(userID, password); err != nil { - auth.AuthenticationFailed.ServeHTTP(w, r) + auth.AuthenticationFailed(err).ServeHTTP(w, r) return } @@ -98,7 +98,7 @@ func (auth *AuthService) Middleware(config *AuthMiddlewareConfig) func(http.Hand return } - auth.AuthenticationFailed.ServeHTTP(w, r) + auth.AuthenticationFailed(err).ServeHTTP(w, r) return } if err != nil { @@ -139,7 +139,7 @@ func (auth *AuthService) Middleware(config *AuthMiddlewareConfig) func(http.Hand } if !hasAll { - auth.AuthenticationFailed.ServeHTTP(w, r) + auth.AuthenticationFailed(err).ServeHTTP(w, r) return } } diff --git a/server/db/database.go b/server/db/database.go index 92a5c06..beb7a87 100644 --- a/server/db/database.go +++ b/server/db/database.go @@ -1,27 +1,33 @@ package db -import "fmt" +import ( + "errors" + "fmt" +) + +var ErrAlreadyExists = errors.New(`object already exists in database`) +var ErrDoesntExist = errors.New(`object doesn't exist in database`) // Entities type User struct { - ID string - Username string - Permissions []string + ID string `json:"id"` + Permissions []string `json:"permissions"` } type Room struct { - ID string - SeatIDs []string + ID string `json:"id"` + SeatIDs []string `json:"seatIds"` // gridRows, gridCols int } type Seat struct { - ID string + ID string `json:"id"` + RoomID string `json:"roomId"` // OccupiedBy is an empty list or a singleton of a userID - OccupiedBy []string + OccupiedBy []string `json:"occupiedBy"` // x, y, w, h int } @@ -29,16 +35,27 @@ type Seat struct { // Database Interfaces type Store interface { + CreateUser(user *User) error + CreateRoom(room *Room) error + CreateSeat(seat *Seat) error + + DeleteUser(user *User) error + DeleteRoom(room *Room) error + DeleteSeat(seat *Seat) error + GetUser(userID string) (*User, error) GetRoom(roomID string) (*Room, error) GetSeat(seatID string) (*Seat, error) - GetOccupiedSeats(roomID string) ([]string, error) - GetFreeSeats(roomID string) ([]string, error) + GetRooms() ([]string, error) + + GetRoomOccupiedSeats(roomID string) ([]string, error) + GetRoomFreeSeats(roomID string) ([]string, error) + GetUserSeat(userID string) ([]string, error) - OccupySeat(userID, roomID, seatID string) error - FreeSeat(userID, roomID, seatID string) error + OccupySeat(seatID, userID string) error + FreeSeat(seatID string) error } // TODO: Create an SQLite implementation @@ -57,47 +74,88 @@ func NewInMemoryStore() Store { } db.rooms["aula-stud"] = &Room{ - ID: "aula-stud", - SeatIDs: []string{ - "aula-stud/posto-1", - "aula-stud/posto-2", - "aula-stud/posto-3", - "aula-stud/posto-4", - }, + ID: "aula-stud", + SeatIDs: []string{}, } - db.seats["aula-stud/posto-1"] = &Seat{ - ID: "aula-stud/posto-1", - OccupiedBy: []string{}, - } - db.seats["aula-stud/posto-2"] = &Seat{ - ID: "aula-stud/posto-2", - OccupiedBy: []string{}, - } - db.seats["aula-stud/posto-3"] = &Seat{ - ID: "aula-stud/posto-3", - OccupiedBy: []string{}, - } - db.seats["aula-stud/posto-4"] = &Seat{ - ID: "aula-stud/posto-4", - OccupiedBy: []string{}, + for i := 1; i <= 11; i++ { + seatID := fmt.Sprintf(`aula-stud/posto-%d`, i) + + db.rooms["aula-stud"].SeatIDs = append(db.rooms["aula-stud"].SeatIDs, seatID) + db.seats[seatID] = &Seat{ + ID: seatID, + RoomID: "aula-stud", + OccupiedBy: []string{}, + } } + db.seats["aula-stud/posto-7"].OccupiedBy = []string{"aziis98"} + db.seats["aula-stud/posto-8"].OccupiedBy = []string{"jack"} + db.users["aziis98"] = &User{ ID: "aziis98", - Username: "aziis98", Permissions: []string{"admin"}, } db.users["bachoseven"] = &User{ ID: "bachoseven", - Username: "bachoseven", Permissions: []string{"admin"}, } return db } +func (db *memDB) CreateUser(user *User) error { + if _, present := db.users[user.ID]; present { + return ErrAlreadyExists + } + db.users[user.ID] = user + return nil +} + +func (db *memDB) CreateRoom(room *Room) error { + if _, present := db.rooms[room.ID]; present { + return ErrAlreadyExists + } + db.rooms[room.ID] = room + return nil +} + +func (db *memDB) CreateSeat(seat *Seat) error { + if _, present := db.seats[seat.ID]; present { + return ErrAlreadyExists + } + db.seats[seat.ID] = seat + return nil +} + +func (db *memDB) DeleteUser(user *User) error { + if _, present := db.users[user.ID]; !present { + return ErrDoesntExist + } + + delete(db.users, user.ID) + return nil +} + +func (db *memDB) DeleteRoom(room *Room) error { + if _, present := db.rooms[room.ID]; !present { + return ErrDoesntExist + } + + delete(db.rooms, room.ID) + return nil +} + +func (db *memDB) DeleteSeat(seat *Seat) error { + if _, present := db.seats[seat.ID]; !present { + return ErrDoesntExist + } + + delete(db.seats, seat.ID) + return nil +} + func (db *memDB) GetUser(userID string) (*User, error) { user, present := db.users[userID] if !present { @@ -125,7 +183,17 @@ func (db *memDB) GetSeat(seatID string) (*Seat, error) { return seat, nil } -func (db *memDB) GetOccupiedSeats(roomID string) ([]string, error) { +func (db *memDB) GetRooms() ([]string, error) { + roomIDs := []string{} + + for roomID := range db.rooms { + roomIDs = append(roomIDs, roomID) + } + + return roomIDs, nil +} + +func (db *memDB) GetRoomOccupiedSeats(roomID string) ([]string, error) { room, err := db.GetRoom(roomID) if err != nil { return nil, err @@ -142,7 +210,7 @@ func (db *memDB) GetOccupiedSeats(roomID string) ([]string, error) { return seats, nil } -func (db *memDB) GetFreeSeats(roomID string) ([]string, error) { +func (db *memDB) GetRoomFreeSeats(roomID string) ([]string, error) { room, err := db.GetRoom(roomID) if err != nil { return nil, err @@ -169,12 +237,12 @@ func (db *memDB) GetUserSeat(userID string) ([]string, error) { return []string{}, nil } -func (db *memDB) OccupySeat(userID string, roomID string, seatID string) error { +func (db *memDB) OccupySeat(seatID, userID string) error { db.seats[seatID].OccupiedBy = []string{userID} return nil } -func (db *memDB) FreeSeat(userID string, roomID string, seatID string) error { +func (db *memDB) FreeSeat(seatID string) error { db.seats[seatID].OccupiedBy = []string{} return nil } diff --git a/server/httputil/response.go b/server/httputil/response.go index 70376c1..96dc404 100644 --- a/server/httputil/response.go +++ b/server/httputil/response.go @@ -7,9 +7,12 @@ import ( type H map[string]interface{} -func WriteJSON(w http.ResponseWriter, data interface{}) error { +func WriteJSON(w http.ResponseWriter, data interface{}) { w.Header().Set("Content-Type", "application/json") - return json.NewEncoder(w).Encode(data) + err := json.NewEncoder(w).Encode(data) + if err != nil { + panic(err) + } } func ReadJSON(r *http.Request, data interface{}) error { diff --git a/server/httputil/serverevents/handler.go b/server/httputil/serverevents/handler.go new file mode 100644 index 0000000..b59b1df --- /dev/null +++ b/server/httputil/serverevents/handler.go @@ -0,0 +1,78 @@ +package serverevents + +import ( + "fmt" + "log" + "net/http" +) + +type Config struct { + Connected func(chan string) + Disconnected func(chan string) +} + +type Handler interface { + http.Handler + Broadcast(message string) +} + +type handler struct { + clients map[chan string]bool + + Connected func(chan string) + Disconnected func(chan string) +} + +func New(config *Config) Handler { + return &handler{ + make(map[chan string]bool), + config.Connected, + config.Disconnected, + } +} + +func (sse *handler) Broadcast(message string) { + for client := range sse.clients { + client <- message + } +} + +func (sse *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + client := make(chan string) + sse.clients[client] = true + + log.Printf(`New connection`) + + if sse.Connected != nil { + sse.Connected(client) + } + + defer func() { + log.Printf(`Connection closed`) + close(client) + delete(sse.clients, client) + if sse.Disconnected != nil { + sse.Disconnected(client) + } + }() + + flusher, _ := w.(http.Flusher) + for { + select { + case message, ok := <-client: + if !ok { + return + } + + fmt.Fprintf(w, "data: %s\n\n", message) + flusher.Flush() + case <-r.Context().Done(): + return + } + } +} diff --git a/server/httputil/serverevents/sse_test.go b/server/httputil/serverevents/sse_test.go new file mode 100644 index 0000000..936b9ac --- /dev/null +++ b/server/httputil/serverevents/sse_test.go @@ -0,0 +1,34 @@ +package serverevents_test + +import ( + "log" + "net/http" + "testing" + "time" + + "git.phc.dm.unipi.it/aziis98/posti-dm/server/httputil/serverevents" +) + +func TestSSE(t *testing.T) { + sse := serverevents.New(&serverevents.Config{ + Connected: func(client chan string) { + go func() { + log.Printf(`New client connected callback`) + client <- "Messaggio 1 per questo client" + time.Sleep(1 * time.Second) + client <- "Messaggio 2 per questo client" + }() + }, + }) + + go func() { + for { + log.Printf(`Broadcasting message`) + sse.Broadcast("Messaggio per tutti") + time.Sleep(1 * time.Second) + } + }() + + http.Handle("/sse", sse) + http.ListenAndServe(":8000", nil) +} diff --git a/server/httputil/serversentevents.go b/server/httputil/serversentevents.go deleted file mode 100644 index 5e50f1b..0000000 --- a/server/httputil/serversentevents.go +++ /dev/null @@ -1,102 +0,0 @@ -package httputil - -import ( - "fmt" - "log" - "net/http" -) - -type SSEHandler struct { - clients map[chan string]bool - - Connected func(chan string) - Disconnected func(chan string) -} - -func (sse *SSEHandler) init() { - if sse.clients == nil { - sse.clients = make(map[chan string]bool) - } -} - -func (sse *SSEHandler) Broadcast(message string) { - sse.init() - - for client := range sse.clients { - client <- message - } -} - -func (sse *SSEHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - sse.init() - - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - w.Header().Set("Access-Control-Allow-Origin", "*") - - client := make(chan string) - sse.clients[client] = true - - log.Printf(`New connection`) - - if sse.Connected != nil { - go sse.Connected(client) - } - - defer func() { - log.Printf(`Connection closed`) - close(client) - delete(sse.clients, client) - if sse.Disconnected != nil { - go sse.Disconnected(client) - } - }() - - flusher, _ := w.(http.Flusher) - for { - select { - case message, ok := <-client: - if !ok { - return - } - - fmt.Fprintf(w, "data: %s\n\n", message) - flusher.Flush() - case <-r.Context().Done(): - return - } - } -} - -// func HandleSSE(handler func(broadcast, single chan<- string)) http.Handler { -// broadcast := make(chan string) - -// return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -// w.Header().Set("Content-Type", "text/event-stream") -// w.Header().Set("Cache-Control", "no-cache") -// w.Header().Set("Connection", "keep-alive") -// w.Header().Set("Access-Control-Allow-Origin", "*") - -// client := make(chan string) -// defer func() { -// close(client) -// }() - -// go handler(broadcast, client) - -// flusher, _ := w.(http.Flusher) -// for { -// select { -// case message := <-broadcast: -// fmt.Fprintf(w, "data: %s\n\n", message) -// flusher.Flush() -// case message := <-client: -// fmt.Fprintf(w, "data: %s\n\n", message) -// flusher.Flush() -// case <-r.Context().Done(): -// return -// } -// } -// }) -// } diff --git a/server/httputil/sse_test.go b/server/httputil/sse_test.go deleted file mode 100644 index 511cf28..0000000 --- a/server/httputil/sse_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package httputil_test - -import ( - "log" - "net/http" - "testing" - "time" - - "git.phc.dm.unipi.it/aziis98/posti-dm/server/httputil" -) - -func TestSSE(t *testing.T) { - sse := &httputil.SSEHandler{ - Connected: func(client chan string) { - log.Printf(`New client connected callback`) - client <- "Messaggio 1 per questo client" - time.Sleep(1 * time.Second) - client <- "Messaggio 2 per questo client" - }, - } - - go func() { - for { - log.Printf(`Broadcasting message`) - sse.Broadcast("Messaggio per tutti") - time.Sleep(1 * time.Second) - } - }() - - http.Handle("/sse", sse) - http.ListenAndServe(":8000", nil) -} diff --git a/server/server.go b/server/server.go index a068190..3128df2 100644 --- a/server/server.go +++ b/server/server.go @@ -7,71 +7,93 @@ import ( "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/alecthomas/repr" "github.com/go-chi/chi/v5" ) type Server struct { - sessions map[string]*db.User authService *auth.AuthService + roomServerEventStreams map[string]serverevents.Handler + Database db.Store ApiRoute chi.Router } func NewServer() *Server { - server := &Server{ - sessions: make(map[string]*db.User), - Database: db.NewInMemoryStore(), - ApiRoute: chi.NewRouter(), - } - - server.authService = &auth.AuthService{ - CheckUserPassword: func(userID, password string) error { - repr.Println("Sessions: ", server.sessions) - - if password != "phc" { - return fmt.Errorf(`invalid password`) - } - - return nil - }, - UserPermissions: func(userID string) ([]string, error) { - user, err := server.Database.GetUser(userID) - if err != nil { - return nil, err - } + sessions := make(map[string]*db.User) + database := db.NewInMemoryStore() - return user.Permissions, nil + 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 + database.CreateUser(&db.User{ + ID: userID, + Permissions: []string{}, + }) + + return nil + }, + UserPermissions: func(userID string) ([]string, error) { + user, err := database.GetUser(userID) + if err != nil { + return nil, err + } + + return user.Permissions, nil + }, + SessionTokenFromUser: func(userID string) (string, error) { + user, err := database.GetUser(userID) + if err != nil { + return "", err + } + + token := util.RandomHash(10) + sessions[token] = user + repr.Println("Sessions: ", sessions) + + 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) + }) + }, }, - SessionTokenFromUser: func(userID string) (string, error) { - user, err := server.Database.GetUser(userID) - if err != nil { - return "", err - } - token := util.RandomHash(10) - server.sessions[token] = user + roomServerEventStreams: make(map[string]serverevents.Handler), - return token, nil - }, - UserFromSessionToken: func(session string) (string, error) { - user, present := server.sessions[session] - if !present { - return "", auth.ErrNoUserForSession - } + ApiRoute: chi.NewRouter(), + } - return user.ID, nil - }, - AuthenticationFailed: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, `not authenticated`, 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) - }) - }, + roomIDs, _ := database.GetRooms() + for _, roomID := range roomIDs { + server.roomServerEventStreams[roomID] = serverevents.New(&serverevents.Config{ + Connected: func(client chan string) { + server.ConnectedToRoom(roomID, client) + }, + }) } server.setupRoutes() @@ -81,7 +103,7 @@ func NewServer() *Server { func (server *Server) setupRoutes() { api := server.ApiRoute - db := server.Database + database := server.Database // Authenticated Routes @@ -104,31 +126,116 @@ func (server *Server) setupRoutes() { server.authService.Logout(w) }) + api.Get("/user", func(w http.ResponseWriter, r *http.Request) { + userID, err := server.authService.UserFromSession(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) + }) + + api.Get("/room/seats", func(w http.ResponseWriter, r *http.Request) { + roomID, ok := r.URL.Query()["id"] + if !ok { + http.Error(w, `missing room id`, http.StatusBadRequest) + } + + room, err := database.GetRoom(roomID[0]) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + seats := []*db.Seat{} + for _, seatID := range room.SeatIDs { + seat, err := database.GetSeat(seatID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + seats = append(seats, seat) + } + + httputil.WriteJSON(w, seats) + }) + + api.HandleFunc("/room_events", func(w http.ResponseWriter, r *http.Request) { + roomID, ok := r.URL.Query()["id"] + if !ok { + http.Error(w, `missing room id`, http.StatusBadRequest) + return + } + + server.roomServerEventStreams[roomID[0]].ServeHTTP(w, r) + }) + api.With(server.authService.LoggedMiddleware()). - Get("/current-seat", func(w http.ResponseWriter, r *http.Request) { + Post("/seat/occupy", func(w http.ResponseWriter, r *http.Request) { userID, err := server.authService.UserFromSession(r) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + seatID, ok := r.URL.Query()["id"] + if !ok { + http.Error(w, `missing seat id`, http.StatusBadRequest) return } - occupiedSeatID, err := db.GetUserSeat(userID) + seat, err := database.GetSeat(seatID[0]) if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + repr.Println(userID, seatID) + + if err := database.OccupySeat(seatID[0], userID); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - if len(occupiedSeatID) == 0 { - httputil.WriteJSON(w, nil) + server.roomServerEventStreams[seat.RoomID].Broadcast("refresh") + httputil.WriteJSON(w, "ok") + }) + api.With(server.authService.LoggedMiddleware()). + Post("/seat/leave", func(w http.ResponseWriter, r *http.Request) { + seatID, ok := r.URL.Query()["id"] + if !ok { + http.Error(w, `missing seat id`, http.StatusBadRequest) return } - seat, err := db.GetSeat(occupiedSeatID[0]) + seat, err := database.GetSeat(seatID[0]) if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + if err := database.FreeSeat(seatID[0]); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - httputil.WriteJSON(w, seat) + server.roomServerEventStreams[seat.RoomID].Broadcast("refresh") + httputil.WriteJSON(w, "ok") }) } + +func (server *Server) ConnectedToRoom(roomID string, client chan string) { + // go func() { + // for { + // client <- "hi from server!" + // time.Sleep(1 * time.Second) + // } + // }() +}