From 87c5992f9f9f73b1e72d71cca15e9703d29dda95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yannik=20R=C3=B6del?= Date: Wed, 16 Mar 2022 15:24:51 +0100 Subject: [PATCH] Add initial backend implementation --- .editorconfig | 3 + .gitignore | 3 + cgi-bin/form.py | 247 ++++++++++++++++++++++++++++++++++ flake.nix | 28 +++- httpd.conf | 33 +++++ package.json | 3 - playground/create-ticket.http | 21 +++ src/backend.mjs | 43 ------ src/content/kontakt/fehler.md | 15 +++ src/content/kontakt/fertig.md | 6 +- 10 files changed, 346 insertions(+), 56 deletions(-) create mode 100755 cgi-bin/form.py create mode 100644 httpd.conf create mode 100644 playground/create-ticket.http delete mode 100644 src/backend.mjs create mode 100644 src/content/kontakt/fehler.md diff --git a/.editorconfig b/.editorconfig index 844771f..a35c7d6 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,3 +7,6 @@ end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true charset = utf-8 + +[*.py] +indent_size = 4 diff --git a/.gitignore b/.gitignore index 5dd3ad5..69d0f2d 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ _site/ .dev # `nix build` output /result + +# Private environments in the HTTP playground folder +/playground/*.private.env.json diff --git a/cgi-bin/form.py b/cgi-bin/form.py new file mode 100755 index 0000000..ad2bf62 --- /dev/null +++ b/cgi-bin/form.py @@ -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
tag doesn't end on the same line. + if "') + + 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") + diff --git a/flake.nix b/flake.nix index 260a220..12fb596 100644 --- a/flake.nix +++ b/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:" diff --git a/httpd.conf b/httpd.conf new file mode 100644 index 0000000..21898bc --- /dev/null +++ b/httpd.conf @@ -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@" ) +} diff --git a/package.json b/package.json index 684c9ac..4b0e5a6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playground/create-ticket.http b/playground/create-ticket.http new file mode 100644 index 0000000..8defbce --- /dev/null +++ b/playground/create-ticket.http @@ -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 + } +} diff --git a/src/backend.mjs b/src/backend.mjs deleted file mode 100644 index b010d8b..0000000 --- a/src/backend.mjs +++ /dev/null @@ -1,43 +0,0 @@ -import express from 'express'; - -/** - * Get Zammad access credentials and verify the connection. - * @param {ReturnType} 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(); diff --git a/src/content/kontakt/fehler.md b/src/content/kontakt/fehler.md new file mode 100644 index 0000000..68f8899 --- /dev/null +++ b/src/content/kontakt/fehler.md @@ -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). diff --git a/src/content/kontakt/fertig.md b/src/content/kontakt/fertig.md index 75886db..e0bfa0d 100644 --- a/src/content/kontakt/fertig.md +++ b/src/content/kontakt/fertig.md @@ -30,6 +30,6 @@ extraStylesheets: ['finish'] /> -**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.