mirror of
https://codeberg.org/angestoepselt/homepage.git
synced 2025-05-24 14:46:16 +00:00
Add initial backend implementation
This commit is contained in:
parent
d45e2d141e
commit
87c5992f9f
10 changed files with 346 additions and 56 deletions
|
|
@ -7,3 +7,6 @@ end_of_line = lf
|
|||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
charset = utf-8
|
||||
|
||||
[*.py]
|
||||
indent_size = 4
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -16,3 +16,6 @@ _site/
|
|||
.dev
|
||||
# `nix build` output
|
||||
/result
|
||||
|
||||
# Private environments in the HTTP playground folder
|
||||
/playground/*.private.env.json
|
||||
|
|
|
|||
247
cgi-bin/form.py
Executable file
247
cgi-bin/form.py
Executable file
|
|
@ -0,0 +1,247 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
import io
|
||||
import cgi
|
||||
import collections
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
from typing import Any, Optional, overload
|
||||
|
||||
import itsdangerous
|
||||
import requests
|
||||
|
||||
|
||||
def fail(status: str, reason: str) -> None:
|
||||
print(f"Status: {status}")
|
||||
print("Content-Type: text/html")
|
||||
print(f"X-Reason: {reason}")
|
||||
print(f"X-Sendfile: {SITE_DIRECTORY}/kontakt/fehler/index.html")
|
||||
print("")
|
||||
exit(0)
|
||||
|
||||
|
||||
SITE_DIRECTORY = os.environ.get("SITE_DIRECTORY", "")
|
||||
if SITE_DIRECTORY == "":
|
||||
fail("503 Service Unavailable", "Cannot open site directory")
|
||||
request_uri = os.environ.get("REQUEST_URI", "").lower().rstrip("/")
|
||||
serializer = itsdangerous.URLSafeSerializer("secret key", "salt")
|
||||
|
||||
|
||||
cookies = dict[str, str]()
|
||||
for entry in os.environ.get("HTTP_COOKIE", "").split(";"):
|
||||
name, *value = entry.lstrip(" ").split("=", 1)
|
||||
cookies[name] = value[0] if len(value) == 1 else ""
|
||||
|
||||
|
||||
# Get the CSRF token. This is pass along according to the double-submit
|
||||
# technique[1]. First as a hidden form input. Second as a cookie, which is
|
||||
# signed on the server, so we can verify its authenticity. The latter is also
|
||||
# used for subsequent requests so every session only gets one token.
|
||||
# 1: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie
|
||||
if "__Host-csrftoken" in cookies:
|
||||
# We already have a token for this client - try and extract it.
|
||||
signed_csrf_token = cookies["__Host-csrftoken"]
|
||||
try:
|
||||
csrf_token = serializer.loads(signed_csrf_token)
|
||||
except:
|
||||
# The token wasn't valid, so we need to generate a new one.
|
||||
csrf_token = secrets.token_urlsafe(32)
|
||||
else:
|
||||
csrf_token = secrets.token_urlsafe(32)
|
||||
# Re-sign the token so it's fresh and the timeout doesn't waive.
|
||||
signed_csrf_token = serializer.dumps(csrf_token)
|
||||
|
||||
|
||||
match os.environ.get("REQUEST_METHOD", "").upper():
|
||||
case "GET":
|
||||
# For GET requests, serve the form that the user requested. The CSRF
|
||||
# token will be added here as well.
|
||||
print("Status: 200")
|
||||
print("Content-Type: text/html")
|
||||
print(f"Set-Cookie: __Host-csrftoken={signed_csrf_token}; path=/; Secure; SameSite=Strict; HttpOnly")
|
||||
print("")
|
||||
|
||||
with open(f"{SITE_DIRECTORY}/{request_uri.strip('/')}/index.html", "r") as template:
|
||||
for line in template.readlines():
|
||||
print(line)
|
||||
# This is a very rudimentary check to ensure that we actually
|
||||
# place the token *inside* the form. It assumes that there is
|
||||
# a) only one form on the site and
|
||||
# b) the <form> tag doesn't end on the same line.
|
||||
if "<form" in line.lower():
|
||||
print(f'<input type="hidden" name="csrftoken" value="{csrf_token}" />')
|
||||
|
||||
exit(0)
|
||||
|
||||
case "POST":
|
||||
# This is the main case where we handle the submitted form. This is
|
||||
# continued below.
|
||||
pass
|
||||
|
||||
case _:
|
||||
# This case should never actually happen because lighttpd filters out
|
||||
# requests accordingly.
|
||||
fail("405 Method Not Allowed", "Method Not Allowed")
|
||||
|
||||
|
||||
form = cgi.FieldStorage()
|
||||
|
||||
@overload
|
||||
def get_form_value(name: str, default: Optional[str], cast: type[str] = str) -> str: ...
|
||||
@overload
|
||||
def get_form_value(name: str, default: Optional[int], cast: type[int]) -> int:...
|
||||
@overload
|
||||
def get_form_value(name: str, default: None = ..., cast: type[io.BytesIO] = ...) -> io.BytesIO:...
|
||||
def get_form_value(
|
||||
name: str,
|
||||
default: Any = None,
|
||||
cast: type[str] | type[int] | type[io.BytesIO] = str,
|
||||
) -> Any:
|
||||
if name not in form:
|
||||
if default is None:
|
||||
fail("400 Bad Request", f"Missing required field: {name}")
|
||||
else:
|
||||
return default
|
||||
|
||||
if cast is io.BytesIO:
|
||||
value_object = form[name]
|
||||
if isinstance(value_object, list) or not value_object.file:
|
||||
fail("400 Bad Request", f"Invalid value for field: {name}")
|
||||
return io.BytesIO(value_object.file)
|
||||
else:
|
||||
try:
|
||||
return cast(form.getfirst(name))
|
||||
except (TypeError, ValueError):
|
||||
fail("400 Bad Request", f"Invalid value for field: {name}")
|
||||
|
||||
|
||||
# Make sure the provided CSRF token is actually valid. Note the usage of the
|
||||
# constant-time string comparison here.
|
||||
given_csrf_token = get_form_value("csrftoken")
|
||||
if not hmac.compare_digest(csrf_token, given_csrf_token):
|
||||
fail("400 Bad Request", f"Invalid CSRF token")
|
||||
|
||||
|
||||
# Extract all the actually provided form data. This is different from form to
|
||||
# form (see the match block below).
|
||||
contact_name = get_form_value("contactname")
|
||||
contact_email = get_form_value("contactemail")
|
||||
message = get_form_value("message")
|
||||
|
||||
ticket_details = collections.OrderedDict()
|
||||
ticket_details["Kontaktperson"] = contact_name
|
||||
ticket_details["Email"] = contact_email
|
||||
|
||||
match request_uri:
|
||||
case "/kontakt":
|
||||
form_name = "Allgemein"
|
||||
|
||||
case "/kontakt/problem":
|
||||
form_name = "Problem-Support"
|
||||
|
||||
case "/geld":
|
||||
form_name = "Geldspende"
|
||||
|
||||
case "/mitmachen":
|
||||
form_name = "Mitgliederwerbung"
|
||||
|
||||
case "/computer-beantragen/organisation":
|
||||
form_name = "Computerantrag (Organisation)"
|
||||
ticket_details["Organisation"] = get_form_value("organization")
|
||||
ticket_details["Adresse"] = get_form_value("addressline")
|
||||
ticket_details["PLZ"] = get_form_value("postalcode")
|
||||
ticket_details["Stadt"] = get_form_value("city")
|
||||
ticket_details["Anzahl Desktops"] = get_form_value("desktopcount", 0, int)
|
||||
ticket_details["Anzahl Laptops"] = get_form_value("laptopcount", 0, int)
|
||||
ticket_details["Anzahl Drucker"] = get_form_value("printercount", 0, int)
|
||||
|
||||
case "/computer-beantragen/privat":
|
||||
form_name = "Computerantrag (privat)"
|
||||
ticket_details["Adresse"] = get_form_value("addressline")
|
||||
ticket_details["PLZ"] = get_form_value("postalcode")
|
||||
ticket_details["Stadt"] = get_form_value("city")
|
||||
#document = get_form_value("document")
|
||||
|
||||
# Note: the actual form contains a checkbox for whether the user has
|
||||
# read the guidelines, but we don't actually bother checking that here.
|
||||
|
||||
case "/hardware-spenden/organisation":
|
||||
form_name = "Hardwarespende (Organisation)"
|
||||
ticket_details["Organisation"] = get_form_value("organization")
|
||||
#inventory = get_form_value("inventory")
|
||||
|
||||
case "/hardware-spenden/privat/laptop":
|
||||
form_name = "Laptopspende (privat)"
|
||||
ticket_details["Gerätedetails"] = get_form_value("device")
|
||||
|
||||
case _:
|
||||
# This case should never actually happen because lighttpd filters out
|
||||
# requests accordingly.
|
||||
fail("400 Bad Request", "Invalid form")
|
||||
|
||||
ticket_details["Kontaktformular"] = form_name
|
||||
|
||||
|
||||
ZAMMAD_URL = os.environ.get("ZAMMAD_URL", "https://ticket.z31.it").rstrip("/")
|
||||
ZAMMAD_TOKEN = os.environ.get("ZAMMAD_TOKEN", "")
|
||||
if ZAMMAD_TOKEN == "":
|
||||
fail("503 Service Unavailable", "Could not get Zammad token")
|
||||
ZAMMAD_GROUP = os.environ.get("ZAMMAD_GROUP", "testgruppe")
|
||||
session = requests.Session()
|
||||
session.headers.update(Authorization=f"Token token={ZAMMAD_TOKEN}")
|
||||
|
||||
try:
|
||||
# Add the actual ticket to the system.
|
||||
response = session.post(
|
||||
f"{ZAMMAD_URL}/api/v1/tickets",
|
||||
json=dict(
|
||||
title=f"Kontaktformular {contact_name} – {form_name}",
|
||||
group=ZAMMAD_GROUP,
|
||||
customer_id=f"guess:{contact_email}",
|
||||
article=dict(
|
||||
type="web",
|
||||
content_type="text/plain",
|
||||
subject=f"Kontaktformular-Anfrage {contact_name}",
|
||||
body=message
|
||||
)
|
||||
)
|
||||
)
|
||||
response.raise_for_status()
|
||||
ticket_id = response.json()["id"]
|
||||
assert isinstance(ticket_id, int)
|
||||
|
||||
# Add a second article to the ticket that contains all the other information
|
||||
# from the contact form.
|
||||
response = session.post(
|
||||
f"{ZAMMAD_URL}/api/v1/ticket_articles",
|
||||
json=dict(
|
||||
ticket_id=ticket_id,
|
||||
type="note",
|
||||
content_type="text/plain",
|
||||
internal=True,
|
||||
body="\n".join(
|
||||
f"{key}: {value}" for key, value in ticket_details.items()
|
||||
)
|
||||
)
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Add a tag to the ticket, denoting which contact form it came from.
|
||||
response = session.post(
|
||||
f"{ZAMMAD_URL}/api/v1/tags/add",
|
||||
json=dict(
|
||||
object="Ticket",
|
||||
o_id=ticket_id,
|
||||
item=f"Kontaktformular – {form_name}",
|
||||
)
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
print("Status: 302 Found")
|
||||
print("Content-Type: text/html")
|
||||
print("Location: /kontakt/fertig")
|
||||
print("")
|
||||
except:
|
||||
fail("500 Internal Server Error", "Backend error")
|
||||
|
||||
28
flake.nix
28
flake.nix
|
|
@ -10,7 +10,6 @@
|
|||
pkgs = import nixpkgs { inherit system; };
|
||||
|
||||
nodejs = pkgs.nodejs-16_x;
|
||||
|
||||
nodePackages = import ./nix/default.nix { inherit pkgs system nodejs; };
|
||||
nodeDependencies = nodePackages.nodeDependencies.override {
|
||||
nativeBuildInputs = with pkgs; [ pkg-config ];
|
||||
|
|
@ -18,7 +17,10 @@
|
|||
dontNpmInstall = true;
|
||||
};
|
||||
|
||||
|
||||
python = pkgs.python310.withPackages (ps: with ps; [
|
||||
itsdangerous
|
||||
requests
|
||||
]);
|
||||
in
|
||||
rec {
|
||||
packages = {
|
||||
|
|
@ -37,15 +39,23 @@
|
|||
'';
|
||||
};
|
||||
|
||||
lighttpdConfig = pkgs.substituteAll {
|
||||
src = ./httpd.conf;
|
||||
inherit (pkgs) lighttpd;
|
||||
inherit (packages) site;
|
||||
inherit python;
|
||||
cgibin = pkgs.copyPathToStore ./cgi-bin;
|
||||
};
|
||||
|
||||
container = pkgs.dockerTools.buildImage {
|
||||
name = "angestoepselt-site-container";
|
||||
tag = "latest";
|
||||
|
||||
config = {
|
||||
Cmd = [
|
||||
"${pkgs.caddy}/bin/caddy"
|
||||
"file-server"
|
||||
"-root" "${packages.site}"
|
||||
"${pkgs.lighttpd}/bin/lighttpd"
|
||||
"-Df"
|
||||
packages.lighttpdConfig
|
||||
];
|
||||
};
|
||||
};
|
||||
|
|
@ -60,7 +70,11 @@
|
|||
name = "devEnv";
|
||||
|
||||
buildInputs = [ pkgs.makeWrapper ];
|
||||
paths = [ nodejs nodeDependencies ];
|
||||
paths = [
|
||||
nodejs
|
||||
nodeDependencies
|
||||
python
|
||||
];
|
||||
|
||||
postBuild = ''
|
||||
wrapProgram "$out/bin/node" \
|
||||
|
|
@ -70,7 +84,7 @@
|
|||
|
||||
shellHook = ''
|
||||
export NODE_PATH=${nodeDependencies}/lib/node_modules
|
||||
export PATH="${nodeDependencies}/bin:${nodejs}/bin:${pkgs.nodePackages.npm-check-updates}/bin:$PATH"
|
||||
export PATH="${nodeDependencies}/bin:${nodejs}/bin:${pkgs.nodePackages.npm-check-updates}/bin:${python}/bin:$PATH"
|
||||
|
||||
echo ""
|
||||
echo " To start editing content, run:"
|
||||
|
|
|
|||
33
httpd.conf
Normal file
33
httpd.conf
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
server.modules += ( "mod_alias", "mod_cgi", "mod_rewrite" )
|
||||
|
||||
server.port = 8001
|
||||
|
||||
include "@lighttpd@/share/lighttpd/doc/config/conf.d/mime.conf"
|
||||
|
||||
server.document-root = "@site@"
|
||||
index-file.names = ( "index.html" )
|
||||
|
||||
|
||||
$HTTP["request-method"] =~ "GET|POST" {
|
||||
url.rewrite = (
|
||||
"^/kontakt" => "/cgi-bin/form.py",
|
||||
"^/kontakt/problem" => "/cgi-bin/form.py",
|
||||
"^/geld" => "/cgi-bin/form.py",
|
||||
"^/mitmachen" => "/cgi-bin/form.py",
|
||||
"^/computer-beantragen/organisation" => "/cgi-bin/form.py",
|
||||
"^/computer-beantragen/privat" => "/cgi-bin/form.py",
|
||||
"^/hardware-spenden/organisation" => "/cgi-bin/form.py",
|
||||
"^/hardware-spenden/privat/laptop" => "/cgi-bin/form.py"
|
||||
)
|
||||
}
|
||||
|
||||
$HTTP["url"] =~ "^/cgi-bin/" {
|
||||
alias.url += ( "/cgi-bin" => "@cgibin@" )
|
||||
|
||||
static-file.exclude-extensions = ( ".py" )
|
||||
cgi.assign = ( ".py" => "@python@/bin/python" )
|
||||
cgi.execute-x-only = "enable"
|
||||
|
||||
cgi.x-sendfile = "enable"
|
||||
cgi.x-sendfile-docroot = ( "@site@" )
|
||||
}
|
||||
|
|
@ -10,9 +10,6 @@
|
|||
"dev:styles": "sass --watch src/styles/:dist/assets/css/"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^4.17.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@11ty/eleventy": "^0.12.1",
|
||||
"@11ty/eleventy-img": "^1.0.0",
|
||||
|
|
|
|||
21
playground/create-ticket.http
Normal file
21
playground/create-ticket.http
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
GET {{instance}}/api/v1/tickets
|
||||
Authorization: Token token={{token}}
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
POST {{instance}}/api/v1/tickets
|
||||
Authorization: Token token={{token}}
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"title": "Testticket",
|
||||
"group": "testgruppe",
|
||||
"customer_id": "guess:hi@example.com",
|
||||
"article": {
|
||||
"subject": "Testticket 2",
|
||||
"body": "Das ist noch ein ein Testticket.",
|
||||
"type": "web",
|
||||
"internal": true
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import express from 'express';
|
||||
|
||||
/**
|
||||
* Get Zammad access credentials and verify the connection.
|
||||
* @param {ReturnType<express>} app
|
||||
*/
|
||||
function initializeZammad(app) {
|
||||
console.info('Checking connection to Zammad...');
|
||||
|
||||
let url = process.env.ZAMMAD_URL || '';
|
||||
if (url === '') {
|
||||
throw new Error(
|
||||
'Could not find the Zammad URL to connect to. Make sure it is provided using the ZAMMAD_URL environment variable. It should point to the root URL of the Zammad installation.'
|
||||
);
|
||||
}
|
||||
url = url.replace(/\/$/, '');
|
||||
|
||||
const token = process.env.ZAMMAD_TOKEN || '';
|
||||
if (token === '') {
|
||||
throw new Error(
|
||||
'Could not find authentication credentials for Zammad. Make sure the token is provided using the ZAMMAD_TOKEN environment variable.'
|
||||
);
|
||||
}
|
||||
|
||||
app.set('zammad url', url);
|
||||
app.set('zammad token', token);
|
||||
|
||||
// TODO Verify the connection
|
||||
}
|
||||
|
||||
function run() {
|
||||
const app = express();
|
||||
|
||||
initializeZammad(app);
|
||||
|
||||
app.post('/kontakt', (req, res) => {});
|
||||
|
||||
const port = parseInt(process.env.LISTEN_PORT || '3000');
|
||||
return app.listen(port, () => {
|
||||
console.log(`Server listening at http://localhost:${port}...`);
|
||||
});
|
||||
}
|
||||
run();
|
||||
15
src/content/kontakt/fehler.md
Normal file
15
src/content/kontakt/fehler.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
layout: layouts/page.njk
|
||||
---
|
||||
|
||||
# Da passt was nicht…
|
||||
|
||||
**Entschuldigung**  — 
|
||||
leider konnten wir dein Formular nicht verarbeiten.
|
||||
Stelle bitte sicher, dass du alle Felder korrekt ausgefüllt hast.
|
||||
|
||||
Außerdem darf dein Browser Cookies nicht blockieren.
|
||||
Das kann auch durch einen Werbeblocker passieren – evtl. musst du für unsere Seite eine Ausnahme hinterlegen.
|
||||
Wir verwenden Cookies nur für die Sicherheit unserer Formulare und binden weder Werbung noch Tracking-Dienste ein.
|
||||
|
||||
Wenn das alles nicht hilft, schreibe uns bitte an [info@angestoepselt.de](mailto:info@angestoepselt.de).
|
||||
|
|
@ -30,6 +30,6 @@ extraStylesheets: ['finish']
|
|||
/>
|
||||
</svg>
|
||||
|
||||
**Wir stöpseln das** – Vielen Dank, wir haben deine Anfrage
|
||||
entgegengenommen und werden uns bei dir melden. Du solltest in Kürze per E-Mail
|
||||
eine entsprechende Bestätigung erhalten.
|
||||
**Vielen Dank** — 
|
||||
wir haben deine Anfrage entgegengenommen und werden uns bei dir melden.
|
||||
Du solltest in Kürze per E-Mail eine entsprechende Bestätigung erhalten.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue