Initial commit

This commit is contained in:
EpicKiwi 2025-01-12 00:55:41 +01:00
commit 3bc7df43f1
Signed by: epickiwi
GPG Key ID: C4B28FD2729941CE
9 changed files with 434 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
src/brochures
cgid.sock.*
apache2-dev.pid

7
README.md Normal file
View File

@ -0,0 +1,7 @@
# Systeme d'archivage de Brochures
## Serveur de dev
```
/sbin/apache2 -d "$(pwd)" -f dev.conf -X
```

14
apache2.conf Normal file
View File

@ -0,0 +1,14 @@
DocumentRoot src
<Directory "src/cgi-bin">
Options +ExecCGI
AddHandler cgi-script .ts
</Directory>
<Directory "src/brochures">
Header set Content-Security-Policy "script-src 'none';"
</Directory>
<Location "/brochures">
Script PUT "/cgi-bin/put.ts"
</Location>

100
dev.conf Normal file
View File

@ -0,0 +1,100 @@
PidFile "apache2-dev.pid"
DefaultRuntimeDir "."
#
# Timeout: The number of seconds before receives and sends time out.
#
Timeout 300
#
# KeepAlive: Whether or not to allow persistent connections (more than
# one request per connection). Set to "Off" to deactivate.
#
KeepAlive On
#
# MaxKeepAliveRequests: The maximum number of requests to allow
# during a persistent connection. Set to 0 to allow an unlimited amount.
# We recommend you leave this number high, for maximum performance.
#
MaxKeepAliveRequests 100
#
# KeepAliveTimeout: Number of seconds to wait for the next request from the
# same client on the same connection.
#
KeepAliveTimeout 5
#
# HostnameLookups: Log the names of clients or just their IP addresses
# e.g., www.apache.org (on) or 204.62.129.132 (off).
# The default is off because it'd be overall better for the net if people
# had to knowingly turn this feature on, since enabling it means that
# each client request will result in AT LEAST one lookup request to the
# nameserver.
#
HostnameLookups Off
# ErrorLog: The location of the error log file.
# If you do not specify an ErrorLog directive within a <VirtualHost>
# container, error messages relating to that virtual host will be
# logged here. If you *do* define an error logfile for a <VirtualHost>
# container, that host's errors will be logged there and not here.
#
ErrorLog /dev/stderr
#
# LogLevel: Control the severity of messages logged to the error_log.
# Available values: trace8, ..., trace1, debug, info, notice, warn,
# error, crit, alert, emerg.
# It is also possible to configure the log level for particular modules, e.g.
# "LogLevel info ssl:warn"
#
LogLevel debug
# Include module configuration:
IncludeOptional /etc/apache2/mods-enabled/*.load
IncludeOptional /etc/apache2/mods-enabled/*.conf
# Include list of ports to listen on
Listen 8080
# AccessFileName: The name of the file to look for in each directory
# for additional configuration directives. See also the AllowOverride
# directive.
#
AccessFileName .htaccess
#
# The following lines prevent .htaccess and .htpasswd files from being
# viewed by Web clients.
#
<FilesMatch "^\.ht">
Require all denied
</FilesMatch>
#
# The following directives define some format nicknames for use with
# a CustomLog directive.
#
# These deviate from the Common Log Format definitions in that they use %O
# (the actual bytes sent including headers) instead of %b (the size of the
# requested file), because the latter makes it impossible to detect partial
# requests.
#
# Note that the use of %{X-Forwarded-For}i instead of %h is not recommended.
# Use mod_remoteip instead.
#
LogFormat "%v:%p %h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" vhost_combined
LogFormat "%h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" combined
LogFormat "%h %l %u %t \"%r\" %>s %O" common
LogFormat "%{Referer}i -> %U" referer
LogFormat "%{User-agent}i" agent
# Include of directories ignores editors' and dpkg's backup files,
# see README.Debian for details.
ScriptSock cgid.sock
Include apache2.conf

View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="fr" vocab="http://schema.org/" resource="." typeof="Book">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>— Archive Infokiosque</title>
</head>
<body>
<h1 property="name"></h1>
<p property="author"></p>
<p>Archivée le <time datetime="" content="" property="uploadDate"></time></p>
<ul>
<li property="encoding" typeof="MediaObject">
<a property="contentUrl" href="original.pdf">
<span property="name">PDF : Brochure archivée</span>
</a>
</li>
</ul>
</body>
</html>

57
src/cgi-bin/put.ts Executable file
View File

@ -0,0 +1,57 @@
#!/bin/env -S deno run --allow-env=PATH_TRANSLATED,REQUEST_METHOD --allow-write=../brochures/
import * as path from "jsr:@std/path";
async function fail(status){
for await(let byte of Deno.stdin.readable){
}
console.log("Status: "+status)
console.log("Content-Type: text/plain")
console.log()
}
let targetPath = Deno.env.get("PATH_TRANSLATED")
if(!targetPath){
await fail(400)
Deno.exit(0)
}
if(Deno.env.get("REQUEST_METHOD") != "PUT"){
await fail(400)
Deno.exit(0)
}
await Deno.mkdir(path.dirname(targetPath), {
recursive: true,
mode: 0o770
})
let targetFile = null;
try {
targetFile = await Deno.open(targetPath, {
write: true,
createNew: true,
mode: 0o660
})
} catch(e) {
if(e.name == "AlreadyExists"){
await fail(409)
Deno.exit(0)
} else {
console.error(e)
throw e
}
}
if(targetFile){
await Deno.stdin.readable.pipeTo(targetFile.writable)
console.log("Status: 201")
console.log("Content-Type: text/plain")
console.log()
console.log("Created")
}

106
src/data.js Normal file
View File

@ -0,0 +1,106 @@
const DATA_URL = new URL("/brochures/", document.baseURI)
const PARSER = new DOMParser()
const SERIALIZER = new XMLSerializer()
const DATE_FORMATTER = new Intl.DateTimeFormat("fr", {dateStyle: "short"})
export async function uploadDocument(documentFile, documentDetails = {}){
let uniqueName = await getUniqueName(documentDetails)
let baseURI = new URL(`./${uniqueName}/`, DATA_URL)
let originalFileName = `${slugify(documentDetails.title)}-original.pdf`
let uploadProgress = fetch(new URL(`./${originalFileName}`, baseURI), {
method: "PUT",
headers: {
"Content-Type": "application/pdf"
},
body: documentFile
})
let page = PARSER.parseFromString(await (await fetch("/brochure.template.html")).text(), "text/html")
page.title = documentDetails.title + " " + page.title
page.querySelector('[property="name"]').textContent = documentDetails.title
page.querySelector('a[href="original.pdf"]').href = "./"+originalFileName
{
let el = page.querySelector('[property="uploadDate"]')
el.textContent = DATE_FORMATTER.format(new Date(documentDetails.uploadDate))
el.setAttribute("datetime", documentDetails.uploadDate);
el.setAttribute("content", documentDetails.uploadDate);
}
if(documentDetails.author){
page.querySelector('[property="author"]').textContent = documentDetails.author
} else {
page.querySelector('[property="author"]').remove()
}
let responses = await Promise.all([
fetch(new URL("./index.html", baseURI), {
method: "PUT",
headers: {
"Content-Type": "text/html"
},
body: SERIALIZER.serializeToString(page)
}),
uploadProgress
]);
for(let res of responses) {
if(!res.ok){
throw new Error(`Network error ${res.status} ${res.statusText}`)
}
}
return baseURI.toString()
}
async function getUniqueName(documentDetails){
let nameCandidate, isUnique;
do {
nameCandidate = getRandomName(documentDetails)
let res = await fetch(new URL(`./${nameCandidate}/index.html`, DATA_URL), {
method: "HEAD"
});
if(!res.ok && res.status != 404){
throw new Error(`Network error ${res.status} ${res.statusText}`)
}
isUnique = res.status == 404
} while(!isUnique)
return nameCandidate
}
function getRandomName(documentDetails){
let source = new Uint8Array(16)
crypto.getRandomValues(source)
let parts = [toHex(source)]
if(documentDetails.author){
parts.push(slugify(documentDetails.author))
}
if(documentDetails.title){
parts.push(slugify(documentDetails.title))
}
return parts.join("-")
}
function toHex(buffer) {
return Array.prototype.map.call(buffer, x => x.toString(16).padStart(2, '0')).join('');
}
function slugify(str){
return str.toLowerCase().replace(/\//g,"-").replace(/\s/g, "-").replace(/--+/, "-")
}

73
src/index.html Normal file
View File

@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Archive Infokiosque</title>
<style>
body {
width: 100%;
box-sizing: border-box;
max-width: 800px;
margin-left: auto;
margin-right: auto;
padding: 25px;
}
h1 {
text-align: center;
margin-bottom: 50px;
}
small {
display: block;
font-size: 0.6em;
}
.form-control label {
display: block;
}
.form-control input {
display: block;
width: 100%;
box-sizing: border-box;
}
</style>
</head>
<body>
<h1>Archive Infokiosque <small>Archivez vos brochures</small></h1>
<noscript>Vous avez besoin d'activer javascript pour archiver quelque chose.</noscript>
<form id="upload-form" hidden>
<p class="form-control">
<label for="brochure-title">Titre de la brochure</label>
<input type="text" name="title" id="brochure-title" required />
</p>
<p class="form-control">
<label for="brochure-author">Auteur / Autrice</label>
<input type="text" name="author" id="brochure-author" />
</p>
<p class="form-control">
<label for="brochure-upload-date">Date d'archivage</label>
<input type="date" id="brochure-upload-date" name="upload-date" />
</p>
<p class="form-control">
<label for="brochure-file">PDF de la brochure</label>
<input type="file" id="brochure-file" accept="application/pdf" name="file" required />
</p>
<button id="submit-button">Archivez moi ca !</button>
</form>
<p id="error-container" hidden><output id="error"></output></p>
<script type="module" src="/index.js"></script>
</body>
</html>

53
src/index.js Normal file
View File

@ -0,0 +1,53 @@
import * as db from "/data.js"
let form = document.getElementById("upload-form")
let submitButton = document.getElementById("submit-button")
let errorContainer = document.getElementById("error-container")
let errorEl = document.getElementById("error")
form.addEventListener("submit", async e => {
e.preventDefault()
submitButton.disabled = true
errorContainer.hidden = true
let data = new FormData(e.target);
try {
if(!data.get("title")){
throw new Error("Veuillez donner le titre de la brochure")
}
if(!data.get("file")){
throw new Error("Aucun fichier selectionne")
}
let details = {
title: data.get("title")
}
if(data.get("author")){
details.author = data.get("author")
}
if(data.get("upload-date")){
details.uploadDate = data.get("upload-date")
}
document.location = await db.uploadDocument(data.get("file"), details)
} catch(e){
errorEl.textContent = `Erreur : ${e.message}`
errorContainer.hidden = false
console.error(e)
}
submitButton.disabled = false
})
// INIT
console.log()
let now = new Date();
form.elements["upload-date"].value = `${now.getFullYear()}-${(now.getMonth()+1).toString().padStart(2, "0")}-${(now.getDate()).toString().padStart(2, "0")}`
form.hidden = false;