9 Commits
29 changed files with 1945 additions and 71 deletions
+23
View File
@@ -0,0 +1,23 @@
# Architecture
## Server, Database and File Uploads
- **server**
The server module defines all routes and extracts data from HTTP request and passes data to the **handler** module.
- **handler**
This is the main controller module that dispatches requests to various services and constructs back responses to send to the client.
### Services
...
- **appunti**
This module manages all things relating the _appunti condivisi_ pages, it talks with the **database** and **auth** modules.
- **database**
This is one of the "leaf" modules that doesn't depend on anything, its purpose is to talk to the database and return "low level data" stored there. For example instances of `time.Time` are represented as `string`s at this layer.
+3 -2
View File
@@ -49,7 +49,7 @@ export default defineConfig([
plugins: [esbuild({ minify: true })],
},
{
input: 'src/appunti-condivisi.jsx',
input: 'src/appunti-condivisi/main.jsx',
output: {
file: 'out/appunti-condivisi.min.js',
format: 'iife',
@@ -68,7 +68,8 @@ export default defineConfig([
],
],
}),
terser(),
// https://rollupjs.org/guide/en/#-w--watch
!process.env.ROLLUP_WATCH && terser(),
],
},
])
-24
View File
@@ -1,24 +0,0 @@
import { render } from 'preact'
import { useState } from 'preact/hooks'
const Counter = ({}) => {
const [value, setValue] = useState(0)
const increment = () => {
setValue(value => value + 1)
}
return (
<p>
Counter: {value} <button onClick={increment}>+</button>
</p>
)
}
render(
<>
<h1>Preact is Working!</h1>
<Counter />
</>,
document.querySelector('#app')
)
+524
View File
@@ -0,0 +1,524 @@
import { render } from 'preact'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import { formatFileSize, intersperse } from './util.js'
function randomHex(length = 16) {
return Array.from({ length }, () => Math.floor(Math.random() * 16).toString(16)).join('')
}
/**
* Lista di tag "standard" disponibili nel completamento per i tags
*/
const availableTags = [
// Genera una ventina di coppie come "2022/2023"
...Array.from({ length: 20 }, (_, i) => {
const year = new Date().getFullYear() - i
return { id: `${year}/${year + 1}`, label: `Anno Accademico ${year}/${year + 1}` }
}),
// List di tag "standard", l'id è la vera stringa del tag, mentre la label
{ id: 'geometria-1', label: 'Geometria 1' },
{ id: 'geometria-2', label: 'Geometria 2' },
{ id: 'eti', label: 'Elementi di Teoria degli Insiemi' },
{ id: 'eta', label: 'Elementi di Topologia Algebrica' },
{ id: 'ega', label: 'Elementi di Geometria Algebrica' },
{ id: 'gtd', label: 'Geometria e Topologia Differenziale' },
{ id: 'ist-anal', label: 'Istituzioni di Analisi' },
{ id: 'ist-geom', label: 'Istituzioni di Geometria' },
{ id: 'ist-fis', label: 'Istituzioni di Fisica' },
{ id: 'ist-prob', label: 'Istituzioni di Probabilità' },
{ id: 'ist-alg', label: 'Istituzioni di Algebra' },
{ id: 'ist-num', label: 'Istituzioni di Analisi Numerica' },
{ id: 'analisi-1', label: 'Analisi 1' },
{ id: 'analisi-2', label: 'Analisi 2' },
{ id: 'aritmetica', label: 'Aritmetica' },
{ id: 'programmazione', label: 'Programmazione' },
{ id: 'fisica-1', label: 'Fisica 1' },
{ id: 'steffe-1', label: 'Laboratorio di Comunicazione Mediante Calcolatore' },
]
const useAutosizeTextarea = ({ minRows } = {}) => {
minRows ??= 1
const textareaRef = useRef(null)
const updateTextareaHeight = useCallback(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px'
}
})
useEffect(() => {
updateTextareaHeight()
})
return {
rows: minRows,
ref: textareaRef,
onInput: updateTextareaHeight,
}
}
const normalizeTag = tag => {
return tag
.toLowerCase()
.replace(/\s+/g, ' ')
.replace(/ /g, '-')
.replace(/[^\p{L}0-9\/\-]/gu, '')
}
const InputTags = ({ tags, setTags, availableTags }) => {
availableTags ??= []
const [id] = useState('tags-' + randomHex())
const nextRef = useRef()
const [nextText, setNextText] = useState('')
const onFocus = e => {
if (!e.target.closest('.tags .tag .remove')) {
nextRef.current?.focus()
}
}
const removeTag = tag => {
setTags(tags => tags.filter(t => t !== tag))
}
const addTag = tag => {
setTags(tags => [...tags, tag])
}
const onKeyDown = e => {
if (e.key === 'Backspace' && nextText.length === 0) {
removeTag(tags.at(-1))
}
const trimmed = nextText.trim()
if (e.key === 'Enter' && trimmed.length > 0) {
addTag(normalizeTag(trimmed))
setNextText('')
}
}
return (
<div class="input-tags" onClick={onFocus}>
{tags.map(tag => (
<div class="tag">
<span>{tag}</span>
<span class="remove" onClick={() => removeTag(tag)}>
<i class="fa-solid fa-remove"></i>
</span>
</div>
))}
<input
type="text"
ref={nextRef}
list={id}
value={nextText}
onInput={e => setNextText(e.target.value)}
onKeyDown={onKeyDown}
/>
<datalist id={id}>
{availableTags.map(({ id, label }) => (
<option value={id} label={label} />
))}
</datalist>
</div>
)
}
const withClasses = a => {
if (Array.isArray(a)) {
return { class: a.filter(className => !!className).join(' ') }
} else if (typeof a === 'object') {
return {
class: Object.entries(a)
.flatMap(([className, active]) => (active ? [className] : []))
.join(' '),
}
} else if (typeof a === 'string') {
return { class: a }
} else {
throw new Error(`Invalid class format`)
}
}
const UploadRegion = ({}) => {
const [draggingOver, setDraggingOver] = useState(false)
const onDragOver = e => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
setDraggingOver(true)
}
const onDragLeave = () => {
setDraggingOver(false)
}
const onDropFiles = files => {
if (files.length !== 1) {
throw new Error('Must drop one file')
}
const [file] = files
console.dir(file)
if (file.type !== 'application/pdf') {
console.error('The file must be a PDF')
}
console.log(formatFileSize(file.size))
}
const onDrop = e => {
e.preventDefault()
if (e.dataTransfer.items) {
onDropFiles(
[...e.dataTransfer.items].flatMap(item =>
item.kind === 'file' ? [item.getAsFile()] : []
)
)
} else {
onDropFiles([...e.dataTransfer.files])
}
setDraggingOver(false)
}
return (
<div
{...withClasses(['upload-region', draggingOver && 'dragging-over'])}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
{!draggingOver ? (
<>
<span>Trascina qui un PDF oppure usa il tasto sottostante</span>
<InputFile accept="application/pdf" />
</>
) : (
<>
<div class="release-text">
<i class="fa-solid fa-upload"></i>
<span>Rilascia per iniziare il caricamento</span>
</div>
</>
)}
</div>
)
}
const Progress = ({ value, max }) => {
return (
<div class="progress-bar">
<div class="indicator" style={{ width: Math.floor((value / max) * 100) + '%' }}></div>
</div>
)
}
const CancellableUpload = ({ uploadedSize, totalSize, onCancel }) => {
return (
<>
<div class="progress-bytes">
{formatFileSize(uploadedSize)} / {formatFileSize(totalSize)}
</div>
<div class="progress-and-action">
<Progress value={uploadedSize} max={totalSize} />
<button onClick={onCancel}>Annulla</button>
</div>
</>
)
}
const UploadPopup = ({}) => {
const [shown, setShown] = useState(false)
const descriptionTextareaProps = useAutosizeTextarea({ minRows: 2 })
const [tags, setTags] = useState(['geometria-1', 'fortuna', 'frigerio', '2013/2014'])
const [doneUploading, setDoneUploading] = useState(false)
const hash = '59e514dd50c63051'
return (
<>
{shown && (
<div class="upload-popup">
<div class="popup">
<div class="header">
<div class="title">
Carica la dispensa "<code>file.pdf</code>"
</div>
</div>
<div class="block">
<p>
{/* TODO: */}
Inserisci titolo, descrizione e tag per questa dispensa, una volta
premuto <strong>Salva</strong> la dispensa verrà aggiunta
inizialmente non sarà visibile nell'elenco degli appunti finché non
verrà approvata da un moderatore
</p>
</div>
<div class="form">
<div class="label">Nome</div>
<input type="text" placeholder="Nome" value="Mezzedimi" />
<div class="label">Descrizione</div>
<textarea placeholder="Descrizione..." {...descriptionTextareaProps}>
Best dispensa di Geometria 1 ever written anche se non in LaTeX
</textarea>
<div class="label">Tags</div>
<InputTags {...{ tags, setTags, availableTags }} />
{!doneUploading ? (
<CancellableUpload
uploadedSize={34.2 * 1024 ** 2}
totalSize={45.6 * 1024 ** 2}
/>
) : (
<div class="right">
<div class="upload-message">
<i class="fa-solid fa-check"></i>
<span title={hash}>File caricato</span>
</div>
<button>Annulla</button>
<button class="primary">Salva</button>
</div>
)}
</div>
</div>
</div>
)}
</>
)
}
const InputFile = ({ accept, onFile }) => {
const inputFileRef = useRef()
const [file, setFile] = useState(null)
const onButtonClick = e => {
inputFileRef.current.click()
}
const onInputFile = e => {
if (e.target.files.length === 1) {
const f = e.target.files[0]
setFile(f)
onFile?.(f)
}
}
return (
<div class="input-file">
<input type="file" ref={inputFileRef} accept={accept} onInput={onInputFile} />
<button onClick={onButtonClick}>Carica File</button>
{file && <div class="file-name">{file.name}</div>}
</div>
)
}
const TabellaApprovazioni = ({ pendingApprovazioni }) => {
return (
<div class="table approvazioni">
<div class="download header"></div>
<div class="hash header">PDF</div>
<div class="title header">Dispensa</div>
<div class="owner header">Proprietario</div>
<div class="actions header">Azioni</div>
{intersperse(
pendingApprovazioni.map(({ id, title, owner, hash }) => (
<>
<div class="download">
<a class="button icon" href={`/appunti/files/${hash}`}>
<i class="fa-solid fa-download"></i>
</a>
</div>
<div class="hash">
<code>{hash}</code>
</div>
<div class="title">
<a href={`/appunti/${id}`} title={id}>
{title}
</a>
</div>
<div class="owner">
<a href={`/u/${owner}`}>@{owner}</a>
</div>
<div class="actions">
<button class="icon">
<i class="fa-solid fa-check"></i>
</button>
<button class="icon">
<i class="fa-solid fa-close"></i>
</button>
</div>
</>
)),
<div class="separator"></div>
)}
</div>
)
}
const App = ({}) => {
const descriptionTextareaProps = useAutosizeTextarea({ minRows: 2 })
const [tags, setTags] = useState(['geometria-1', 'fortuna', 'frigerio', '2013/2014'])
return (
<>
<UploadRegion />
<UploadPopup />
<div class="flex col gap-1 fill-h">
<h1>Le tue dispense</h1>
<div class="table dispense">
<div class="edit header"></div>
<div class="name header">Nome</div>
<div class="tags header">Tags</div>
<div class="status header">Stato</div>
<div class="edit">
<button class="icon flat">
<i class="fa-solid fa-angle-down"></i>
</button>
</div>
<div class="name">Appunti di Geometria 1</div>
<div class="tags">
<div class="tag">geometria-1</div>
<div class="tag">prof-1</div>
<div class="tag">2016/2017</div>
</div>
<div class="status approved">
<i class="fa-solid fa-check"></i>
</div>
<div class="separator"></div>
<div class="expanded">
<div class="edit-close">
<button class="icon flat">
<i class="fa-solid fa-angle-up"></i>
</button>
</div>
<div class="edit-container">
<div class="header">
<a href="/appunti/6f82dca3d83b475c">
<span class="title">Mezzedimi</span>
</a>
</div>
<div class="form">
<div class="label">Nome</div>
<input type="text" placeholder="Nome" value="Mezzedimi" />
<div class="label">Descrizione</div>
<textarea
placeholder="Descrizione..."
{...descriptionTextareaProps}
>
Best dispensa di Geometria 1 ever written anche se non in LaTeX
</textarea>
<div class="label">Tags</div>
<InputTags {...{ tags, setTags, availableTags }} />
</div>
<p>
Puoi anche caricare una nuova versione del PDF ma ricorda che se
carichi un file non precedentemente approvato inizialmente
scomparirà dall'elenco principale in attesa di apporvazione da parte
di un moderatore.
</p>
<div class="form">
<div class="label">Stato</div>
<div class="stato-approvazione approved">
<i class="fa-solid fa-check"></i>
Approvata
</div>
<div class="label">Cambia PDF</div>
<InputFile accept="application/pdf" />
<div class="right">
<button class="primary">Salva</button>
</div>
</div>
</div>
</div>
<div class="separator"></div>
<div class="edit">
<button class="icon flat">
<i class="fa-solid fa-angle-down"></i>
</button>
</div>
<div class="name pending">Appunti di Geometria 2</div>
<div class="tags">
<div class="tag">geometria-2</div>
<div class="tag">prof-2</div>
<div class="tag">2017/2018</div>
<div class="tag">tanti-tag-1</div>
<div class="tag">tanti-tag-2</div>
<div class="tag">tanti-tag-3</div>
<div class="tag">tanti-tag-4</div>
<div class="tag">tanti-tag-5</div>
<div class="tag">tanti-tag-6</div>
</div>
<div class="status pending" title="In attesa di approvazione...">
<i class="fas fa-hourglass"></i>
</div>
<div class="separator"></div>
<div class="edit">
<button class="icon flat">
<i class="fa-solid fa-angle-down"></i>
</button>
</div>
<div class="name rejected">F1Le SuP3R LeGaLe</div>
<div class="tags">
<div class="tag">foo</div>
<div class="tag">bar</div>
</div>
<div class="status rejected">
<i class="fa-solid fa-xmark"></i>
</div>
</div>
</div>
<div class="flex col gap-1 fill-h">
<h1>PDF da Approvare</h1>
<TabellaApprovazioni
pendingApprovazioni={[
{
id: '59e514dd50c63051',
title: 'GAAL',
owner: 'mezzedimi',
hash: '2c8d593c6be289ab',
},
{
id: '4c52d9726c438f3d',
title: 'Dispensa 1',
owner: 'persona-1',
hash: '346ba392a3a1eb86',
},
{
id: 'eecb96f04e319c4c',
title: 'Dispensa 2',
owner: 'persona-1',
hash: '74f9652c28f82e7f',
},
]}
/>
</div>
</>
)
}
render(<App />, document.querySelector('#app'))
+17
View File
@@ -0,0 +1,17 @@
export function formatFileSize(bytes) {
if (bytes < 1024) {
return `${bytes} bytes`
}
if (bytes < 1024 ** 2) {
return `${(bytes / 1024).toFixed(1)} KB`
}
if (bytes < 1024 ** 3) {
return `${(bytes / 1024 ** 2).toFixed(1)} MB`
}
return `${(bytes / 1024 ** 3).toFixed(1)} GB`
}
export function intersperse(list, separator) {
return list.flatMap((el, i) => (i === 0 ? [el] : [separator, el]))
}
+602 -9
View File
@@ -5,10 +5,12 @@
--bg: #eaeaea;
--fg: #333;
--fg-lighter: #444;
--bg-lighter: #f0f0f0;
--bg-dark: hsl(220, 5%, 93%);
--bg-darker: hsl(220, 5%, 90%);
--bg-dark: #ecedee;
--bg-darker: #e4e5e7;
--bg-darker-2: #d5d5d5;
--bg-darker-2-1: #c8c8c8;
--bg-darker-3: #c0c0c0;
@@ -36,6 +38,8 @@
--accent-2: #4eaa59;
--accent-2-darker: #2e974c;
--accent-2-darkest: #002d0d;
--accent-mix-bg: #dae4db;
}
* {
@@ -420,6 +424,11 @@ hr {
background-color: var(--bg-darker-2);
}
sub,
sup {
font-size: 12px;
}
pre {
margin: 0.5rem 0;
@@ -445,6 +454,74 @@ p.center {
/* Controls */
.progress-bar {
background: var(--bg-darker-2-1);
color: var(--fg);
border-radius: 4px;
border: 1px solid var(--bg-darker-2);
box-shadow: 0 0 8px 0 #00000020, inset 0 0 8px 0 #00000030;
overflow: hidden;
height: 2rem;
}
.progress-bar .indicator {
border-radius: 4px;
height: 100%;
background: linear-gradient(to top, var(--accent-1), var(--accent-2));
position: relative;
overflow: hidden;
animation: test-progressbar 2s ease-in-out infinite;
}
@keyframes test-progressbar {
0% {
width: 0%;
}
100% {
width: 100%;
}
}
.progress-bar .indicator::after {
content: '';
position: absolute;
inset: 0;
/* background-size: 50px 50px;
--highlight: rgba(255, 255, 255, 0.1);
background-image: linear-gradient(
-45deg,
transparent 0%,
var(--highlight) 5%,
var(--highlight) 25%,
transparent 30%,
transparent 50%,
var(--highlight) 55%,
var(--highlight) 75%,
transparent 80%,
transparent 100%
);
animation: move 4s linear infinite; */
}
@keyframes move {
0% {
background-position: 0 0;
}
100% {
background-position: 50px 50px;
}
}
a:not(.button) {
color: var(--accent-1-fg);
font-weight: var(--font-weight-medium);
@@ -464,7 +541,7 @@ a.button {
button,
.button {
display: inline-block;
display: flex;
font-family: var(--font-sf);
font-weight: var(--font-weight-medium);
@@ -488,6 +565,9 @@ button,
box-shadow: 0 4px 8px 0 #00000022;
cursor: pointer;
align-items: center;
justify-content: center;
}
button:hover,
@@ -496,21 +576,35 @@ button:hover,
box-shadow: 0 4px 8px 0 #00000033;
}
button.primary {
button.primary,
.button.primary {
border: 1px solid var(--accent-2-darker);
background: var(--accent-2);
color: var(--accent-2-darkest);
}
button.primary:hover {
button.primary:hover,
.button.primary:hover {
background: var(--accent-2-lighter);
}
button.icon {
button.icon,
.button.icon {
padding: 0;
width: 2rem;
}
button.flat {
border: none;
background: none;
box-shadow: none;
}
button.flat:hover {
background: var(--bg-darker-2);
box-shadow: none;
}
select {
font-family: var(--font-sf);
font-weight: var(--font-weight-medium);
@@ -539,12 +633,14 @@ select {
/* Text Fields */
input[type='text'],
input[type='password'] {
input[type='file'],
input[type='password'],
textarea {
border: none;
background: none;
height: 2rem;
padding: 0 0.5rem;
height: 2.25rem;
padding: 0.25rem 0.5rem;
font-size: 17px;
@@ -560,6 +656,17 @@ input[type='password'] {
font-weight: var(--font-weight-normal);
}
textarea {
height: auto;
min-height: 2rem;
padding: 0.5rem;
line-height: 1.5;
}
input[type='file'] {
padding: 0.5rem;
}
input[type='password'] {
font-family: caption;
}
@@ -577,6 +684,21 @@ input[type='password'].error {
color: #311;
}
/* Better File Input */
.input-file {
display: flex;
align-items: center;
gap: 0.5rem;
}
.input-file > input[type='file'] {
position: fixed;
top: -100px;
}
/* Compound Controls */
.compound {
@@ -845,6 +967,458 @@ form .field-set input {
height: calc(var(--size) * 1rem);
}
/* Appunti Condivisi */
.page-appunti-condivisi .main {
padding: 0 1rem inherit;
max-width: 95ch;
}
.page-appunti-condivisi .upload-region {
padding: 2rem;
width: 30rem;
height: 30vh;
border: 3px dashed var(--bg-darker-4);
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 1rem;
transition: border-color 150ms ease-in-out, background-color 150ms ease-in-out;
}
.page-appunti-condivisi .upload-region.dragging-over {
border-color: var(--accent-1);
color: var(--accent-1-fg);
background: var(--accent-mix-bg);
}
/* Serve altrimenti l'evento dropleave viene emesso anche quando si passa da ".upload-region" ad uno dei figli, così invece rendiamo tutti i figli trasparenti agli eventi del mouse */
.page-appunti-condivisi .upload-region.dragging-over * {
pointer-events: none;
}
.page-appunti-condivisi .upload-region.dragging-over .release-text {
display: flex;
gap: 0.5rem;
font-size: 22px;
font-weight: var(--font-weight-medium);
}
.page-appunti-condivisi .upload-popup {
z-index: 100;
background-color: #00000066;
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
/* backdrop-filter: blur(0.5rem); */
}
.page-appunti-condivisi .upload-popup .popup {
position: relative;
top: 50%;
transform: translateY(-50%);
background: var(--bg-dark);
border-radius: 4px;
border: 1px solid var(--bg-darker-2);
box-shadow: 0 0 32px #00000033;
min-width: 40ch;
max-width: 60ch;
display: flex;
flex-direction: column;
gap: 1rem;
}
.page-appunti-condivisi .upload-popup .popup .block {
padding: 0 1rem;
}
.page-appunti-condivisi .upload-popup .popup .form {
padding: 0 1rem 1rem;
gap: 1rem;
}
.page-appunti-condivisi .popup .form .label {
min-width: 10ch;
}
.form .progress-bytes {
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
.page-appunti-condivisi .upload-message {
font-size: 16px;
color: #666;
user-select: none;
display: flex;
align-items: center;
gap: 0.5rem;
}
.form .progress-and-action {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 1rem;
}
.page-appunti-condivisi .upload-popup .popup > .header {
background: var(--bg-darker);
border-bottom: 1px solid var(--bg-darker-2);
color: var(--fg-lighter);
padding: 1rem;
display: flex;
justify-content: space-between;
}
.page-appunti-condivisi .upload-popup .popup .title {
font-size: 20px;
font-weight: var(--font-weight-medium);
display: flex;
align-items: center;
}
.page-appunti-condivisi .upload-popup .popup .title code {
font-size: 90%;
}
.page-appunti-condivisi #app {
display: flex;
flex-direction: column;
align-items: center;
gap: 3rem;
width: 100%;
}
.page-appunti-condivisi .table {
width: 100%;
display: grid;
background: var(--bg-dark);
border-radius: 4px;
border: 1px solid var(--bg-darker-2);
box-shadow: var(--shadow-1);
overflow: hidden;
}
.page-appunti-condivisi .table.dispense {
grid-template-columns: auto 1fr minmax(auto, 50ch) auto;
}
.page-appunti-condivisi .table.dispense .pending {
color: royalblue;
}
.page-appunti-condivisi .table.dispense .rejected {
color: darkred;
}
.page-appunti-condivisi .table > .header {
font-weight: var(--font-weight-medium);
background: var(--bg-darker);
border-bottom: 1px solid var(--bg-darker-2);
}
.page-appunti-condivisi .table .separator {
grid-column: 1 / -1;
border-bottom: 1px solid var(--bg-darker-2);
}
.page-appunti-condivisi .table.dispense .edit,
.page-appunti-condivisi .table.dispense .name,
.page-appunti-condivisi .table.dispense .tags,
.page-appunti-condivisi .table.dispense .status {
padding: 1rem 0 1rem 1rem;
display: flex;
align-items: center;
}
.page-appunti-condivisi .table.dispense .tags {
flex-wrap: wrap;
gap: 0.5rem;
}
.page-appunti-condivisi .table.dispense .status {
place-self: center;
padding: 1rem 1.25rem 1rem 2rem;
}
.page-appunti-condivisi .table.dispense .expanded {
grid-column: span 4;
display: grid;
grid-template-columns: auto 1fr;
}
.page-appunti-condivisi .table.dispense .expanded p {
width: 100%;
}
.page-appunti-condivisi .table.dispense .edit-container {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem 1rem 1rem 0;
}
.page-appunti-condivisi .table.dispense .edit-container > .header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.page-appunti-condivisi .table.dispense .edit-container > .header code {
padding: 0 0.125rem;
}
.page-appunti-condivisi .table.dispense .edit-container > .header .title {
font-size: 24px;
font-weight: var(--font-weight-medium);
}
.page-appunti-condivisi .table.dispense .edit-container .stato-approvazione {
font-weight: var(--font-weight-medium);
display: flex;
align-items: center;
gap: 1rem;
}
.page-appunti-condivisi .table.dispense .edit-container .stato-approvazione.approved {
color: var(--accent-1);
}
.page-appunti-condivisi .table.dispense h1 {
margin: 0;
}
.page-appunti-condivisi .table.dispense .edit-close {
padding: 1rem;
}
.page-appunti-condivisi .table.approvazioni {
grid-template-columns: auto auto 1fr auto auto;
width: 80ch;
max-width: 100%;
}
.page-appunti-condivisi .table.approvazioni .download,
.page-appunti-condivisi .table.approvazioni .hash,
.page-appunti-condivisi .table.approvazioni .title,
.page-appunti-condivisi .table.approvazioni .owner,
.page-appunti-condivisi .table.approvazioni .actions {
padding: 1rem;
display: flex;
align-items: center;
}
.page-appunti-condivisi .table.approvazioni .hash {
padding-left: 0;
}
.page-appunti-condivisi .table.approvazioni .actions {
display: flex;
gap: 1rem;
justify-content: center;
}
.page-appunti-condivisi .form {
display: grid;
grid-template-columns: auto 1fr;
gap: 1rem 2rem;
align-items: center;
}
.page-appunti-condivisi .form button:not(.icon) {
min-width: 6rem;
}
.page-appunti-condivisi .form > .label {
height: 2rem;
align-self: flex-start;
display: flex;
align-items: center;
font-weight: var(--font-weight-medium);
}
.page-appunti-condivisi .form > textarea {
resize: vertical;
overflow-y: hidden;
}
.page-appunti-condivisi .form > .fill {
grid-column: span 2;
}
.page-appunti-condivisi .form > .right {
grid-column: span 2;
display: flex;
justify-content: end;
gap: 1rem;
}
.page-appunti-condivisi .input-tags {
padding: 0.5rem;
min-height: 2rem;
font-size: 17px;
background: var(--text-input-bg);
color: var(--fg);
border-radius: 4px;
border: 1px solid var(--bg-darker-2);
box-shadow: 0 0 8px 0 #00000020;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.page-appunti-condivisi .input-tags .tag {
user-select: none;
height: 1.5rem;
border-radius: calc(1.5rem / 2);
padding: 0 calc(1.5rem / 2);
background: var(--bg-darker-2);
color: var(--card-date);
font-size: 15px;
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
}
.page-appunti-condivisi .input-tags .tag .remove {
cursor: pointer;
}
.page-appunti-condivisi .input-tags .tag .remove:hover {
color: var(--card-content);
}
.page-appunti-condivisi .input-tags input[type='text'] {
box-shadow: none;
border: none;
background: none;
height: 1.5rem;
min-height: unset;
max-width: 10rem;
display: inline-block;
}
/* Animazioni */
/* Pending hourglass with a bit of overshoot */
/* @keyframes pending-rotate {
0% {
transform: rotate(0deg);
}
25% {
transform: rotate(0deg);
}
30% {
transform: rotate(-5deg);
}
45% {
transform: rotate(190deg);
}
50% {
transform: rotate(180deg);
}
60% {
transform: rotate(175deg);
}
75% {
transform: rotate(185deg);
}
95% {
transform: rotate(370deg);
}
100% {
transform: rotate(360deg);
}
} */
@keyframes pending-rotate {
0% {
transform: rotate(0deg);
}
30% {
transform: rotate(0deg);
}
50% {
transform: rotate(180deg);
}
80% {
transform: rotate(180deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes rotate {
0% {
transform: rotate(0deg);
@@ -943,3 +1517,22 @@ table td {
table tbody tr:hover {
background: var(--bg-darker);
}
/* Utils */
.flex {
display: flex;
align-items: center;
}
.flex.col {
flex-direction: column;
}
.flex.gap-1 {
gap: 1rem;
}
.fill-h {
width: 100%;
}
+2 -1
View File
@@ -59,7 +59,8 @@ body.dark-mode {
filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.4));
}
.dark-mode input[type] {
.dark-mode input[type],
.dark-mode textarea {
background: #4b4342;
}
+5 -7
View File
@@ -3,12 +3,10 @@
{{define "title"}}Condivisione Appunti &bull; PHC{{end}}
{{define "body"}}
<section>
<h1>
<i class="fas fa-book"></i>
Condivisione Appunti
</h1>
<div id="app"></div>
</section>
<h1>
<i class="fas fa-book"></i>
Condivisione Appunti
</h1>
<div id="app"></div>
<script src="/public/appunti-condivisi.min.js"></script>
{{end}}
+14
View File
@@ -0,0 +1,14 @@
{{template "base" .}}
{{define "title"}}{{ .Dispensa.Title }} &bull; Appunti &bull; PHC{{end}}
{{define "body"}}
<section>
<h1>
<i class="fas fa-book"></i>
{{ .Dispensa.Title }}
</h1>
<p>
{{ .Dispensa.Description }}
</p>
{{end}}
+55
View File
@@ -0,0 +1,55 @@
package appunti
import (
"git.phc.dm.unipi.it/phc/website/database"
"git.phc.dm.unipi.it/phc/website/model"
)
// Service isola l'handler dal modulo del database e si occupa solo delle interazioni riguardanti gli appunti e le dispense
type Service interface {
GetDispensa(id string) (*model.Dispensa, error)
CreateDispensa(template model.Dispensa) (string, error)
}
type DefaultService struct {
DB *database.DB
}
var _ Service = &DefaultService{}
func (s *DefaultService) GetDispensa(id string) (*model.Dispensa, error) {
dispensa, err := s.DB.Dispense.Get(id)
if err != nil {
return nil, err
}
tags, err := s.DB.DispensaTags.Get(id)
if err != nil {
return nil, err
}
return &model.Dispensa{
Id: dispensa.Id,
OwnerId: dispensa.OwnerId,
Title: dispensa.Title,
Description: dispensa.Description,
Tags: tags,
}, nil
}
func (s *DefaultService) CreateDispensa(template model.Dispensa) (string, error) {
dispensaId, err := s.DB.Dispense.Create(database.Dispensa{
OwnerId: template.OwnerId,
Title: template.Title,
Description: template.Description,
})
if err != nil {
return "", err
}
if err := s.DB.DispensaTags.Set(dispensaId, template.Tags); err != nil {
return "", err
}
return dispensaId, nil
}
+16 -11
View File
@@ -3,28 +3,32 @@ package main
import (
"log"
"git.phc.dm.unipi.it/phc/website/appunti"
"git.phc.dm.unipi.it/phc/website/articles"
"git.phc.dm.unipi.it/phc/website/auth"
"git.phc.dm.unipi.it/phc/website/config"
"git.phc.dm.unipi.it/phc/website/database/sqlite"
"git.phc.dm.unipi.it/phc/website/handler"
"git.phc.dm.unipi.it/phc/website/lista_utenti"
"git.phc.dm.unipi.it/phc/website/server"
"git.phc.dm.unipi.it/phc/website/storia"
"git.phc.dm.unipi.it/phc/website/templates"
"git.phc.dm.unipi.it/phc/website/util"
)
func main() {
config.Load()
authService := auth.NewDefaultService(config.AuthServiceHost)
auth := auth.NewDefaultService(config.AuthServiceHost)
listaUtentiService, err := lista_utenti.New(authService, config.ListaUtenti)
if err != nil {
log.Fatal(err)
// Create database connection and apply pending migrations
db := util.Must(sqlite.New("phc-server.local.db"))
if err := db.Migrate("./database/migrations"); err != nil {
panic(err)
}
h := &handler.DefaultHandler{
AuthService: authService,
app := server.NewFiberServer(&handler.DefaultHandler{
AuthService: auth,
Renderer: templates.NewRenderer(
"./_views/",
"./_views/base.html",
@@ -35,13 +39,14 @@ func main() {
Storia: &storia.JsonFileStoria{
Path: "./_content/storia.yaml",
},
ListaUtenti: listaUtentiService,
}
app := server.NewFiberServer(h)
ListaUtenti: util.Must(lista_utenti.New(auth, config.ListaUtenti)),
Appunti: &appunti.DefaultService{
DB: db,
},
})
log.Printf("Starting server on host %q", config.Host)
if err := app.Listen(config.Host); err != nil {
log.Fatal(err)
panic(err)
}
}
+16
View File
@@ -0,0 +1,16 @@
package main
import (
"git.phc.dm.unipi.it/phc/website/database/sqlite"
)
func main() {
db, err := sqlite.New("phc-server.local.db")
if err != nil {
panic(err)
}
if err := db.Migrate("database/migrations"); err != nil {
panic(err)
}
}
+50
View File
@@ -0,0 +1,50 @@
package database
type DBMigrate interface {
Migrate(migrationDir string) error
}
type DBQueryAppunti interface {
AllDispensaFile() ([]DispensaFile, error)
}
type DBDispense interface {
Create(template Dispensa) (string, error)
Get(id string) (Dispensa, error)
All() ([]Dispensa, error)
Update(d Dispensa) error
Delete(id string) error
}
type DBUploads interface {
Create(template Upload) (string, error)
Get(id string) (Upload, error)
}
type DBFileApprovals interface {
Create(template FileApproval) (string, error)
Get(id string) (FileApproval, error)
All() ([]FileApproval, error)
Update(d FileApproval) error
Delete(id string) error
}
type DBDispensaTags interface {
Set(dispensaId string, tags []string) error
Get(dispensaId string) ([]string, error)
}
type DBDownloads interface {
Create(template Download) error
}
type DB struct {
DBMigrate
DBQueryAppunti
Dispense DBDispense
Uploads DBUploads
FileApprovals DBFileApprovals
DispensaTags DBDispensaTags
Downloads DBDownloads
}
+43
View File
@@ -0,0 +1,43 @@
-- Dispense
CREATE TABLE IF NOT EXISTS "dispense"(
"id" TEXT NOT NULL PRIMARY KEY,
"created_at" TEXT NOT NULL,
"owner_id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL
);
-- Contenuto caricato
CREATE TABLE IF NOT EXISTS "uploads"(
"id" TEXT NOT NULL PRIMARY KEY,
"created_at" TEXT NOT NULL,
"owner_id" TEXT NOT NULL,
"dispensa_id" TEXT NOT NULL,
"file" TEXT NOT NULL,
FOREIGN KEY (dispensa_id) REFERENCES dispense(id)
);
-- Approvazioni contenuti caricati
CREATE TABLE IF NOT EXISTS "file_approvals"(
"id" TEXT NOT NULL PRIMARY KEY,
"created_at" TEXT NOT NULL,
"owner_id" TEXT NOT NULL, -- Moderatore che ha creato questa approvazione
"upload_id" TEXT NOT NULL, -- Upload a cui si riferisce questa approvazione
"status" TEXT NOT NULL, -- Risultato dell'approvazione: "approved" | "rejected"
FOREIGN KEY (upload_id) REFERENCES uploads(id)
);
-- Download counter
CREATE TABLE IF NOT EXISTS "downloads"(
"dispensa_id" TEXT NOT NULL,
"timestamp" TEXT NOT NULL,
FOREIGN KEY (dispensa_id) REFERENCES dispense(id)
);
-- Tags per le dispense
CREATE TABLE IF NOT EXISTS "tags"(
"dispensa_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
FOREIGN KEY (dispensa_id) REFERENCES dispense(id)
);
+53
View File
@@ -0,0 +1,53 @@
package database
//
// Tables
//
type Dispensa struct {
Id string `db:"id"`
CreatedAt string `db:"created_at"`
OwnerId string `db:"owner_id"`
Title string `db:"title"`
Description string `db:"description"`
}
type Upload struct {
Id string `db:"id"`
CreatedAt string `db:"created_at"`
OwnerId string `db:"owner_id"`
DispensaId string `db:"dispensa_id"`
File string `db:"file"`
}
type FileApproval struct {
Id string `db:"id"`
CreatedAt string `db:"created_at"`
OwnerId string `db:"owner_id"`
UploadId string `db:"upload_id"`
Status string `db:"status"` // "approved" | "rejected"
}
type Download struct {
DispensaId string `db:"dispensa_id"`
Timestamp string `db:"timestamp"`
}
type Tag struct {
DispensaId string `db:"dispensa_id"`
Tag string `db:"tag"`
}
//
// Common Joins
//
// DispensaFile is a join of dispensa with the most recent upload for that "dispensa_id"
type DispensaFile struct {
DispensaId string `db:"dispensa_id"`
UploadId string `db:"upload_id"`
OwnerId string `db:"owner_id"`
Title string `db:"title"`
Description string `db:"description"`
File string `db:"file"`
}
+34
View File
@@ -0,0 +1,34 @@
package sqlite
import (
"git.phc.dm.unipi.it/phc/website/database"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
)
type migration struct {
Timestamp string
Filename string
}
func New(filename string) (*database.DB, error) {
db, err := sqlx.Open("sqlite3", filename+"?_fk=1")
if err != nil {
panic(err)
}
if err := db.Ping(); err != nil {
return nil, err
}
return &database.DB{
DBMigrate: &sqliteDBMigrate{db},
// DBQueryAppunti: ...,
Dispense: &sqliteDBDispense{db},
Uploads: &sqliteDBUploads{db},
FileApprovals: &sqliteDBFileApprovals{db},
Downloads: &sqliteDBDownloads{db},
DispensaTags: &sqliteDBTags{db},
}, nil
}
+82
View File
@@ -0,0 +1,82 @@
package sqlite
import (
"fmt"
"log"
"os"
"path"
"time"
"git.phc.dm.unipi.it/phc/website/database"
"github.com/jmoiron/sqlx"
)
type sqliteDBMigrate struct{ *sqlx.DB }
var _ database.DBMigrate = sqliteDBMigrate{}
func (db sqliteDBMigrate) Migrate(migrationFolder string) error {
log.Printf(`Creating migrations table`)
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS migrations(timestamp TEXT, filename TEXT)`); err != nil {
return err
}
log.Printf(`Loading applied migrations`)
appliedMigrations := []migration{}
if err := db.Select(&appliedMigrations, `SELECT * FROM migrations`); err != nil {
return err
}
entries, err := os.ReadDir(migrationFolder)
if err != nil {
return err
}
for i, entry := range entries {
if entry.IsDir() {
return fmt.Errorf("no dirs in migrations folder")
}
if i < len(appliedMigrations) {
if appliedMigrations[i].Filename != entry.Name() {
return fmt.Errorf("misapplied migration %q with %q", appliedMigrations[i].Filename, entry.Name())
}
log.Printf("Found applied migration %q", entry.Name())
continue
}
log.Printf("Applying new migration %q", entry.Name())
migrationPath := path.Join(migrationFolder, entry.Name())
sqlStmts, err := os.ReadFile(migrationPath)
if err != nil {
return err
}
tx, err := db.Beginx()
if err != nil {
return err
}
if _, err := tx.Exec(string(sqlStmts)); err != nil {
return err
}
if _, err := tx.Exec(`INSERT INTO migrations VALUES (?, ?)`,
time.Now().Format(time.RFC3339),
entry.Name(),
); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
}
log.Printf("All migrations applied successfully, database up to date")
return nil
}
+68
View File
@@ -0,0 +1,68 @@
package sqlite
import (
"time"
"git.phc.dm.unipi.it/phc/website/database"
"git.phc.dm.unipi.it/phc/website/util"
"github.com/jmoiron/sqlx"
)
type sqliteDBDispense struct{ *sqlx.DB }
var _ database.DBDispense = sqliteDBDispense{}
func (d sqliteDBDispense) Create(template database.Dispensa) (string, error) {
template.Id = util.GenerateRandomString(8)
template.CreatedAt = time.Now().Format(time.RFC3339)
if _, err := d.DB.NamedExec(`
INSERT INTO
dispense(id, created_at, owner_id, title, description)
VALUES
(:id, :created_at, :owner_id, :title, :description)
`, &template); err != nil {
return "", err
}
return template.Id, nil
}
func (d sqliteDBDispense) Get(id string) (database.Dispensa, error) {
var dispensa database.Dispensa
if err := d.DB.Get(&dispensa, `SELECT * FROM dispense WHERE id = ?`, id); err != nil {
return database.Dispensa{}, err
}
return dispensa, nil
}
func (d sqliteDBDispense) All() ([]database.Dispensa, error) {
var dispense []database.Dispensa
if err := d.DB.Select(&dispense, `SELECT * FROM dispense`); err != nil {
return nil, err
}
return dispense, nil
}
func (d sqliteDBDispense) Update(dispensa database.Dispensa) error {
if _, err := d.DB.NamedExec(`
UPDATE
dispense
SET
owner_id = :owner_id
title = :title,
description = :description
WHERE
id = :id
`, &dispensa); err != nil {
return err
}
return nil
}
func (d sqliteDBDispense) Delete(id string) error {
panic("TODO: Not implemented")
}
+35
View File
@@ -0,0 +1,35 @@
package sqlite
import (
"git.phc.dm.unipi.it/phc/website/database"
"git.phc.dm.unipi.it/phc/website/util"
"github.com/jmoiron/sqlx"
)
type sqliteDBUploads struct{ *sqlx.DB }
var _ database.DBUploads = sqliteDBUploads{}
func (u sqliteDBUploads) Create(template database.Upload) (string, error) {
template.Id = util.GenerateRandomString(8)
if _, err := u.DB.NamedExec(`
INSERT INTO
uploads(id, created_at, owner_id, dispensa_id, file)
VALUES
(:id, :created_at, :owner_id, :dispensa_id, :file)
`, &template); err != nil {
return "", err
}
return template.Id, nil
}
func (u sqliteDBUploads) Get(id string) (database.Upload, error) {
var upload database.Upload
if err := u.DB.Select(`SELECT * FROM uploads WHERE id = ?`, id); err != nil {
return database.Upload{}, err
}
return upload, nil
}
+66
View File
@@ -0,0 +1,66 @@
package sqlite
import (
"time"
"git.phc.dm.unipi.it/phc/website/database"
"git.phc.dm.unipi.it/phc/website/util"
"github.com/jmoiron/sqlx"
)
type sqliteDBFileApprovals struct{ *sqlx.DB }
func (f sqliteDBFileApprovals) Create(template database.FileApproval) (string, error) {
template.Id = util.GenerateRandomString(8)
template.CreatedAt = time.Now().Format(time.RFC3339)
if _, err := f.DB.NamedExec(`
INSERT INTO
file_approvals(id, created_at, owner_id, upload_id, status)
VALUES
(:id, :created_at, :owner_id, :upload_id, :status)
`, &template); err != nil {
return "", err
}
return template.Id, nil
}
func (f sqliteDBFileApprovals) Get(id string) (database.FileApproval, error) {
var fileApproval database.FileApproval
if err := f.DB.Get(&fileApproval, `SELECT * FROM file_approvals WHERE id = ?`, id); err != nil {
return database.FileApproval{}, err
}
return fileApproval, nil
}
func (f sqliteDBFileApprovals) All() ([]database.FileApproval, error) {
var fileApprovals []database.FileApproval
if err := f.DB.Select(&fileApprovals, `SELECT * FROM file_approvals`); err != nil {
return nil, err
}
return fileApprovals, nil
}
func (f sqliteDBFileApprovals) Update(fileApproval database.FileApproval) error {
if _, err := f.DB.NamedExec(`
UPDATE
file_approvals
SET
owner_id = :owner_id,
upload_id = :upload_id,
status = :status
WHERE
id = :id
`, &fileApproval); err != nil {
return err
}
return nil
}
func (f sqliteDBFileApprovals) Delete(id string) error {
panic("not implemented") // TODO: Implement
}
+23
View File
@@ -0,0 +1,23 @@
package sqlite
import (
"git.phc.dm.unipi.it/phc/website/database"
"github.com/jmoiron/sqlx"
)
type sqliteDBDownloads struct{ *sqlx.DB }
var _ database.DBDownloads = sqliteDBDownloads{}
func (d sqliteDBDownloads) Create(template database.Download) error {
if _, err := d.DB.NamedExec(`
INSERT INTO
downloads(dispensa_id, timestamp)
VALUES
(:dispensa_id, :timestamp)
`, &template); err != nil {
return err
}
return nil
}
+52
View File
@@ -0,0 +1,52 @@
package sqlite
import (
"git.phc.dm.unipi.it/phc/website/database"
"github.com/jmoiron/sqlx"
)
type sqliteDBTags struct{ *sqlx.DB }
var _ database.DBDispensaTags = sqliteDBTags{}
func (t sqliteDBTags) Set(dispensaId string, tags []string) error {
tagsRows := []map[string]any{}
for _, t := range tags {
tagsRows = append(tagsRows, map[string]any{
"dispensa_id": dispensaId,
"name": t,
})
}
tx, err := t.DB.Beginx()
if err != nil {
return err
}
if _, err := tx.Exec(`
DELETE FROM tags WHERE dispensa_id = ?
`, dispensaId); err != nil {
return err
}
if _, err := tx.NamedExec(`
INSERT INTO tags(dispensa_id, name) VALUES (:dispensa_id, :name)
`, tagsRows); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
func (t sqliteDBTags) Get(dispensaId string) ([]string, error) {
var tags []string
if err := t.DB.Select(&tags, `SELECT name FROM tags WHERE dispensa_id = ?`, dispensaId); err != nil {
return nil, err
}
return tags, nil
}
+2
View File
@@ -18,7 +18,9 @@ require (
github.com/andybalholm/brotli v1.0.4 // indirect
github.com/dlclark/regexp2 v1.4.0 // indirect
github.com/gorilla/feeds v1.1.1 // indirect
github.com/jmoiron/sqlx v1.3.5 // indirect
github.com/klauspost/compress v1.15.6 // indirect
github.com/mattn/go-sqlite3 v1.14.15 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.37.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
+7
View File
@@ -15,21 +15,28 @@ github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55k
github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/gofiber/fiber/v2 v2.34.0 h1:96BJMw6uaxQhJsHY54SFGOtGgp9pgombK5Hbi4JSEQA=
github.com/gofiber/fiber/v2 v2.34.0/go.mod h1:ozRQfS+D7EL1+hMH+gutku0kfx1wLX4hAxDCtDzpj4U=
github.com/gofiber/redirect/v2 v2.1.23 h1:MqRyyeKyGqkF4GIFgTB4SuqIeeXviUglgRL2HCOFofM=
github.com/gofiber/redirect/v2 v2.1.23/go.mod h1:IYF5pPLDLYrrHMcxajDyWV+nHMbyPd6agCXkCnfLxS0=
github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY=
github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.15.6 h1:6D9PcO8QWu0JyaQ2zUMmu16T1T+zjjEpP91guRsvDfY=
github.com/klauspost/compress v1.15.6/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/litao91/goldmark-mathjax v0.0.0-20210217064022-a43cf739a50f h1:plCPYXRXDCO57qjqegCzaVf1t6aSbgCMD+zfz18POfs=
github.com/litao91/goldmark-mathjax v0.0.0-20210217064022-a43cf739a50f/go.mod h1:leg+HM7jUS84JYuY120zmU68R6+UeU6uZ/KAW7cViKE=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+79 -9
View File
@@ -5,6 +5,7 @@ import (
"html/template"
"io"
"git.phc.dm.unipi.it/phc/website/appunti"
"git.phc.dm.unipi.it/phc/website/articles"
"git.phc.dm.unipi.it/phc/website/auth"
"git.phc.dm.unipi.it/phc/website/lista_utenti"
@@ -16,22 +17,52 @@ import (
)
type Service interface {
//
// Pages
//
HandleStaticPage(w io.Writer, view string, ctx Context) error
HandleUtenti() ([]*model.User, error)
HandleListaUtenti() ([]*model.ListUser, error)
// Storia
HandleStoriaPage(w io.Writer, ctx Context) error
HandleQueryAppunti(w io.Writer, query string, ctx Context) error
// Appunti
HandleAppuntiPage(w io.Writer, query string, ctx Context) error
HandleAppuntiCondivisiPage(w io.Writer, ctx Context) error
HandleDispensaPage(w io.Writer, id string, ctx Context) error
// News
HandleNewsPage(w io.Writer, ctx Context) error
// Guide
HandleGuidePage(w io.Writer, ctx Context) error
// User
HandleProfilePage(w io.Writer, ctx Context) error
// Article Pages
HandleNewsArticlePage(w io.Writer, articleID string, ctx Context) error
HandleGuideArticlePage(w io.Writer, articleID string, ctx Context) error
// RSS
HandleNewsFeedPage(w io.Writer) error
HandleGuideFeedPage(w io.Writer) error
//
// API
//
// User
HandleLogin(username, password string) (*model.Session, error)
HandleUser(token string) *model.User
HandleRequiredUser(ctx Context) (*model.User, error)
HandleProfilePage(w io.Writer, ctx Context) error
HandleNewsArticlePage(w io.Writer, articleID string, ctx Context) error
HandleGuideArticlePage(w io.Writer, articleID string, ctx Context) error
HandleNewsFeedPage(w io.Writer) error
HandleGuideFeedPage(w io.Writer) error
// User Listing
HandleUtenti() ([]*model.User, error)
HandleListaUtenti() ([]*model.ListUser, error)
// Appunti
HandleCreateDispensa(template model.Dispensa, ctx Context) (string, error)
HandleGetDispensa(dispensaId string) (*model.Dispensa, error)
}
//
@@ -48,6 +79,7 @@ func (ctx Context) getUser() *model.User {
// Handler holds references to abstract services for easy testing provided by every module (TODO: Make every field an interface of -Service)
type DefaultHandler struct {
AuthService auth.Service
Appunti appunti.Service
Renderer *templates.TemplateRenderer
NewsArticlesRegistry *articles.Registry
GuideArticlesRegistry *articles.Registry
@@ -55,6 +87,8 @@ type DefaultHandler struct {
Storia storia.StoriaService
}
var _ Service = &DefaultHandler{}
func (h *DefaultHandler) HandleStaticPage(w io.Writer, view string, ctx Context) error {
return h.Renderer.Render(w, view, util.Map{
"User": ctx.getUser(),
@@ -91,7 +125,7 @@ func (h *DefaultHandler) HandleStoriaPage(w io.Writer, ctx Context) error {
})
}
func (h *DefaultHandler) HandleQueryAppunti(w io.Writer, query string, ctx Context) error {
func (h *DefaultHandler) HandleAppuntiPage(w io.Writer, query string, ctx Context) error {
return h.Renderer.Render(w, "appunti.html", util.Map{
"User": ctx.getUser(),
"Query": query,
@@ -104,6 +138,18 @@ func (h *DefaultHandler) HandleAppuntiCondivisiPage(w io.Writer, ctx Context) er
})
}
func (h *DefaultHandler) HandleDispensaPage(w io.Writer, id string, ctx Context) error {
dispensa, err := h.Appunti.GetDispensa(id)
if err != nil {
return err
}
return h.Renderer.Render(w, "dispensa.html", util.Map{
"User": ctx.getUser(),
"Dispensa": dispensa,
})
}
func (h *DefaultHandler) HandleNewsPage(w io.Writer, ctx Context) error {
articles, err := h.NewsArticlesRegistry.GetArticles()
if err != nil {
@@ -221,3 +267,27 @@ func (h *DefaultHandler) HandleGuideFeedPage(w io.Writer) error {
return guideFeed.WriteRss(w)
}
//
// API
//
func (h *DefaultHandler) HandleCreateDispensa(template model.Dispensa, ctx Context) (string, error) {
user := ctx.getUser()
if user == nil {
return "", ErrNoUser
}
template.OwnerId = user.Username
dispensaId, err := h.Appunti.CreateDispensa(template)
if err != nil {
return "", err
}
return dispensaId, nil
}
func (h *DefaultHandler) HandleGetDispensa(id string) (*model.Dispensa, error) {
return h.Appunti.GetDispensa(id)
}
+9
View File
@@ -0,0 +1,9 @@
package model
type Dispensa struct {
Id string `json:"id"`
OwnerId string `json:"ownerId"`
Title string `json:"title"`
Description string `json:"description"`
Tags []string `json:"tags"`
}
+51 -5
View File
@@ -1,13 +1,15 @@
package server
import (
"log"
"time"
"git.phc.dm.unipi.it/phc/website/config"
"git.phc.dm.unipi.it/phc/website/handler"
"git.phc.dm.unipi.it/phc/website/model"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/etag"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/fiber/v2/middleware/recover"
"github.com/gofiber/redirect/v2"
)
@@ -43,7 +45,7 @@ func routes(h handler.Service, r fiber.Router) {
//
r.Use(logger.New())
r.Use(recover.New())
// r.Use(recover.New())
// Remove trailing slash from URLs
r.Use(redirect.New(redirect.Config{
@@ -52,9 +54,29 @@ func routes(h handler.Service, r fiber.Router) {
},
}))
// Better static file serving setup
r.Route("/public", func(r fiber.Router) {
// Cache client side files based on checksum
r.Use(etag.New())
staticConfig := fiber.Static{}
if config.Mode == "development" {
log.Printf("Disabling Cache-Control in development mode")
// if no "Cache-Control" is present the browser will cache heuristically (and we don't want that)
r.Use(func(c *fiber.Ctx) error {
c.Set("Cache-Control", "no-cache")
return c.Next()
})
staticConfig.CacheDuration = 1 * time.Millisecond
}
r.Static("/", "./_frontend/out", staticConfig)
r.Static("/", "./_public", staticConfig)
})
// Serve generated css and js files and the static "./_public" folder
r.Static("/public/", "./_frontend/out")
r.Static("/public/", "./_public")
// Process all request and add user to the request context if there is a session cookie
r.Use(UserMiddleware(h))
@@ -92,7 +114,7 @@ func routes(h handler.Service, r fiber.Router) {
query := c.Query("q", "")
c.Type("html")
return h.HandleQueryAppunti(c, query, CreateContext(c))
return h.HandleAppuntiPage(c, query, CreateContext(c))
})
r.Get("/appunti/condivisi", func(c *fiber.Ctx) error {
@@ -199,4 +221,28 @@ func routesApi(h handler.Service, r fiber.Router) {
return c.JSON(user)
})
r.Post("/appunti/dispense", func(c *fiber.Ctx) error {
var dispensaTemplate model.Dispensa
if err := c.BodyParser(&dispensaTemplate); err != nil {
return err
}
dispensaId, err := h.HandleCreateDispensa(dispensaTemplate, CreateContext(c))
if err != nil {
return err
}
return c.JSON(dispensaId)
})
r.Get("/appunti/dispense", func(c *fiber.Ctx) error {
dispensaId := c.Query("id")
dispensa, err := h.HandleGetDispensa(dispensaId)
if err != nil {
return err
}
return c.JSON(dispensa)
})
}
+11
View File
@@ -0,0 +1,11 @@
package util
import "log"
func Must[T any](value T, err error) T {
if err != nil {
log.Fatal(err)
}
return value
}
+3 -3
View File
@@ -2,11 +2,11 @@ package util
import (
"crypto/rand"
"encoding/base64"
"encoding/hex"
"log"
)
// GenerateRandomString generates a random base64 string from a random array of "n" bytes (to prevent padding let n be a multiple of 3)
// GenerateRandomString generates a random hex string from a random array of "n" bytes
func GenerateRandomString(n int) string {
b := make([]byte, n)
@@ -15,5 +15,5 @@ func GenerateRandomString(n int) string {
log.Fatal(err)
}
return base64.URLEncoding.EncodeToString(b)
return hex.EncodeToString(b)
}