ctucx.git: travelynx2fedi

Automaticly post travelynx checkins on the fediverse

commit 7304c23e6aa58a9e243d79fd1a01f759b6b5ba88
Author: Leah (ctucx) <git@ctu.cx>
Date: Tue, 6 Jun 2023 16:51:48 +0200

initial commit
13 files changed, 602 insertions(+), 0 deletions(-)
A
.gitignore
|
3
+++
A
example-config.ini
|
13
+++++++++++++
A
flake.lock
|
61
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
flake.nix
|
45
+++++++++++++++++++++++++++++++++++++++++++++
A
nim.cfg
|
2
++
A
nixosModule.nix
|
99
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/fedi.nim
|
45
+++++++++++++++++++++++++++++++++++++++++++++
A
src/requestHandler.nim
|
177
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/threadvars.nim
|
8
++++++++
A
src/travelynx2fedi.nim
|
69
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/types.nim
|
17
+++++++++++++++++
A
src/utils.nim
|
50
++++++++++++++++++++++++++++++++++++++++++++++++++
A
travelynx2fedi.nimble
|
13
+++++++++++++
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,3 @@
+config.ini
+state
+travelynx2fedi
diff --git a/example-config.ini b/example-config.ini
@@ -0,0 +1,13 @@
+[server]
+port = 8080        # optinal, defaults to 8080 if not defined, can also be overridden by the env-var `TRAVELYNX2FEDI_PORT`
+token = "test123"  # optional, access-token reqired to use service
+
+[travelynx]
+username = "MaxMustermensch" # optinal, travelynx username - required to build the checkin url
+
+[fedi]
+url         = "https://your-server.tld"                          # required, url to your instance incl. scheme
+accessToken = "MJA4ZDE0MJKTMJI4OS0ZM2I0LWI5NJETZJJIODLINTE0ODA0" # required, access-token required to post to the fediverse
+visibility  = "direct"                                           # optional, hardcoded status visibility 
+spoilerText = "travelynx"                                        # optional
+useMarkdown = "yes"                                              # optional, use markdown in posts, only use this when your instance supports markdown
diff --git a/flake.lock b/flake.lock
@@ -0,0 +1,61 @@
+{
+  "nodes": {
+    "flake-utils": {
+      "inputs": {
+        "systems": "systems"
+      },
+      "locked": {
+        "lastModified": 1685518550,
+        "narHash": "sha256-o2d0KcvaXzTrPRIo0kOLV0/QXHhDQ5DTi+OxcjO8xqY=",
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "rev": "a1720a10a6cfe8234c0e93907ffe81be440f4cef",
+        "type": "github"
+      },
+      "original": {
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "type": "github"
+      }
+    },
+    "nixpkgs": {
+      "locked": {
+        "lastModified": 1685865905,
+        "narHash": "sha256-XJZ/o17eOd2sEsGif+/MQBnfa2DKmndWgJyc7CWajFc=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "e7603eba51f2c7820c0a182c6bbb351181caa8e7",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "ref": "nixos-23.05",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "root": {
+      "inputs": {
+        "flake-utils": "flake-utils",
+        "nixpkgs": "nixpkgs"
+      }
+    },
+    "systems": {
+      "locked": {
+        "lastModified": 1681028828,
+        "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+        "owner": "nix-systems",
+        "repo": "default",
+        "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+        "type": "github"
+      },
+      "original": {
+        "owner": "nix-systems",
+        "repo": "default",
+        "type": "github"
+      }
+    }
+  },
+  "root": "root",
+  "version": 7
+}
diff --git a/flake.nix b/flake.nix
@@ -0,0 +1,44 @@
+{
+  description = "Automaticly post travelynx checkins on the fediverse";
+
+  inputs = {
+    flake-utils.url = "github:numtide/flake-utils";
+    nixpkgs.url     = "github:NixOS/nixpkgs/nixos-23.05";
+  };
+
+  outputs = { self, nixpkgs, flake-utils }: {
+
+    nixosModule = import ./nixosModule.nix;
+
+    overlay     = final: prev: {
+
+      travelynx2fedi = final.nimPackages.buildNimPackage {
+        name        = "travelynx2fedi";
+        src         = self;
+
+        nimBinOnly  = true;
+        nimRelease  = true;
+      };
+
+    };
+
+  } // (flake-utils.lib.eachDefaultSystem (system:
+    let
+      pkgs = import nixpkgs {
+        inherit system;
+        overlays = [ self.overlay ];
+      };
+
+    in rec {
+
+      packages.default        = pkgs.travelynx2fedi;
+      packages.travelynx2fedi = pkgs.travelynx2fedi;
+
+      apps.default = {
+        type = "app";
+        program = "${pkgs.travelynx2fedi}/bin/travelynx2fedi";
+      };
+
+    }
+  ));
+}+
\ No newline at end of file
diff --git a/nim.cfg b/nim.cfg
@@ -0,0 +1 @@
+d:ssl+
\ No newline at end of file
diff --git a/nixosModule.nix b/nixosModule.nix
@@ -0,0 +1,99 @@
+{ options, config, pkgs, lib, ... }:
+
+with lib;
+
+let
+  cfg            = config.services.travelynx2fedi;
+  settingsFormat = pkgs.formats.json {};
+
+in {
+
+  options = {
+    services.travelynx2fedi = with lib; {
+      enable = mkEnableOption "travelynx2fedi - post travelynx checkins to the fediverse";
+
+      nginx = {
+        enable     = mkEnableOption "";
+        enableACME = mkEnableOption "";
+
+        domain = mkOption {
+          type = types.str;
+        };
+
+        path = mkOption {
+          type    = types.str;
+          default = "= /travelynx2fedi";
+        };
+      };
+
+      package = mkOption {
+        type    = types.package;
+        default = pkgs.travelynx2fedi;
+      };
+
+      port = mkOption {
+        type     = types.int;
+        default  = 8321;
+      };
+
+      configFile = mkOption {
+        type     = types.str;
+        default  = "/var/lib/travelynx2fedi/config.ini";
+      };
+
+    };
+  };
+
+
+  config = lib.mkIf cfg.enable {
+
+    services.nginx = lib.mkIf cfg.nginx.enable {
+      virtualHosts."${cfg.nginx.domain}" = {
+        enableACME = lib.mkIf cfg.nginx.enableACME true;
+        forceSSL   = lib.mkIf cfg.nginx.enableACME true;
+        locations."${cfg.nginx.path}".proxyPass = "http://[::1]:${builtins.toString cfg.port}";
+      };
+    };
+
+    systemd.services.travelynx2fedi = {
+      after           = [ "network-online.target" ];
+      wantedBy        = [ "multi-user.target" ];
+
+      environment = {
+        TRAVELYNX2FEDI_PORT         = builtins.toString cfg.port;
+        TRAVELYNX2FEDI_CONFIG_PATH  = cfg.configFile;
+      };
+
+      serviceConfig = {
+        DynamicUser = true;
+
+        Type = "exec";
+        StateDirectory     = "travelynx2fedi";
+        StateDirectoryMode = "750";
+
+        Restart    = "always";
+        RestartSec = 3;
+
+        ExecStart = "${cfg.package}/bin/travelynx2fedi";
+
+        NoNewPrivileges = true;
+        PrivateTmp      = true;
+        PrivateDevices  = false;
+
+        RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6";
+        RestrictNamespaces      = true;
+        RestrictRealtime        = true;
+      
+        ProtectSystem         = "full";
+        ProtectControlGroups  = true;
+        ProtectKernelModules  = true;
+        ProtectKernelTunables = true;
+
+        DevicePolicy     = "closed";
+        LockPersonality  = true;
+      };
+    };
+
+  };
+
+}
diff --git a/src/fedi.nim b/src/fedi.nim
@@ -0,0 +1,45 @@
+import std/[asyncdispatch, httpclient]
+import std/[json, options]
+
+import types
+
+proc fediPost* (config: Config, text: string, visibility: PostVisibility): Future[JsonNode] {.async.} =
+  let client = newAsyncHttpClient(userAgent = "travelynx2fedi", headers = newHttpHeaders({
+    "Authorization": "Bearer " &  config.FediAccessToken
+  }))
+
+  var postData = newMultipartData({
+    "status":     text,
+    "language":   "de",
+    "visibility": $visibility
+  })
+
+  if config.FediSpoilerText.isSome:
+    postData["spoiler_text"] = config.FediSpoilerText.get
+
+  let request = await client.request(
+    httpmethod = HttpPost,
+    url        = config.FediURL & "/api/v1/statuses",
+    multipart  = postData
+  )
+
+  result                 = parseJson(await request.body)
+  result["responseCode"] = newJString($request.code)
+  
+  client.close()
+
+
+proc fediDelete* (config: Config, id: string): Future[JsonNode] {.async.} =
+  let client = newAsyncHttpClient(userAgent = "travelynx2fedi", headers = newHttpHeaders({
+    "Authorization": "Bearer " &  config.FediAccessToken
+  }))
+
+  let request = await client.request(
+    httpmethod = HttpDelete,
+    url        = config.FediURL & "/api/v1/statuses/" & id,
+  )
+
+  result                 = parseJson(await request.body)
+  result["responseCode"] = newJString($request.code)
+  
+  client.close()
diff --git a/src/requestHandler.nim b/src/requestHandler.nim
@@ -0,0 +1,177 @@
+import std/[asyncdispatch, asynchttpserver]
+import std/[parseutils, strutils]
+import std/times
+import std/[json, options]
+
+import threadvars, types, utils, fedi
+
+proc saveState(state: JsonNode) = 
+  var json_encoded: string
+  toUgly(json_encoded, state)
+  writeFile(storagePath & "/state.json", json_encoded)
+
+proc requestHandler* (req: Request) {.async.} =
+  echo $req.reqMethod & " " & $req.url.path & " " & req.headers.getOrDefault("user-agent") & ": " & req.body
+
+  let headers = {"Content-type": "text/plain; charset=utf-8"}
+
+
+  if config.ServerAccessToken.isSome:
+    if not req.headers.hasKey("Authorization"):
+      await req.respond(Http401, "Wrong access-token provided.", headers.newHttpHeaders())
+      return
+    else:
+      if req.headers.getOrDefault("Authorization") != "Bearer " & config.ServerAccessToken.get:
+        await req.respond(Http401, "Wrong access-token provided.", headers.newHttpHeaders())
+        return
+
+  try:
+    let entityBodyJson = parseJson(req.body)
+    var reason         = entityBodyJson["reason"].getStr
+
+    if reason == "checkout": reason = "update" 
+
+    case reason:
+      of "ping":
+        state = newJObject()
+        saveState(state)
+
+        var response = "[fedi-create-post] error: Unkown error happend!"
+
+        let post = await fediPost(config, "This is a test post from travelynx2fedi!", PostDirect)
+
+        if post["responseCode"].getStr != "200 OK":
+          response = "[fedi-create-post] error: " & post["error"].getStr
+        else:
+          response = "[fedi-create-post] created post " & $post["id"] & ": " & $post["content"]
+
+        await req.respond(Http200, response, headers.newHttpHeaders())
+        return
+
+
+      of "checkin":
+        state = newJObject()
+        saveState(state)
+        await req.respond(Http200, "[checkin] recieved checkin!", headers.newHttpHeaders())
+        return
+
+
+      of "update":
+        var statusUrl:        string
+        var statusText:       string
+        var statusVisibility: PostVisibility = PostDirect
+
+        var response:     string
+        var responseCode: HttpCode = Http200
+
+        if state.hasKey("checkin_id") and state.hasKey("to_station"):
+
+          if (
+            state["checkin_id"].getStr == entityBodyJson["status"]["fromStation"]["scheduledTime"].getStr and
+            state["to_station"].getStr == entityBodyJson["status"]["toStation"]["name"].getStr and
+            state["comment"].getStr    == entityBodyJson["status"]["comment"].getStr
+          ):
+            await req.respond(Http200, "[checkin] Already posted!" & response, headers.newHttpHeaders())
+            return
+
+          if (
+            state.hasKey("post_id") and
+            (
+              state["to_station"].getStr != entityBodyJson["status"]["toStation"]["name"].getStr or
+              state["comment"].getStr    != entityBodyJson["status"]["comment"].getStr
+            )
+          ):
+            let post = await fediDelete(config, state["post_id"].getStr)
+
+            if post["responseCode"].getStr != "200 OK":
+              responseCode = Http500
+              response = "[fedi-delete-post] error: " & post["error"].getStr
+              await req.respond(responseCode, "[checkin] " & response, headers.newHttpHeaders())
+              return
+            else:
+              state = newJObject()
+              saveState(state)
+              response = "[fedi-delete-post] successfully deleted post " & $post["id"]
+
+
+        if config.TravelynxUsername.isSome:
+          statusUrl = "http://travelynx.de/status/" & config.TravelynxUsername.get & "/" & $entityBodyJson["status"]["fromStation"]["scheduledTime"]
+
+        let vehicle = entityBodyJson["status"]["train"]["type"].getStr & " " & entityBodyJson["status"]["train"]["no"].getStr
+
+        if config.FediUseMarkdown:
+          statusText = "Ich bin gerade in [" & vehicle & " nach " & entityBodyJson["status"]["toStation"]["name"].getStr & "](" & statusUrl & ")"
+        else:
+          statusText = "Ich bin gerade in " & vehicle & " nach " & entityBodyJson["status"]["toStation"]["name"].getStr & " " & statusUrl
+
+        if entityBodyJson["status"]{"comment"}.getStr != "":
+          if config.FediUseMarkdown:
+            statusText = entityBodyJson["status"]["comment"].getStr & " ([" & vehicle & " → " & entityBodyJson["status"]["toStation"]["name"].getStr & "](" & statusUrl & "))"
+          else:
+            statusText = entityBodyJson["status"]["comment"].getStr & " (" & vehicle & " → " & entityBodyJson["status"]["toStation"]["name"].getStr & " " & statusUrl & ")"
+
+        if config.FediVisibility.isSome:
+          statusVisibility = config.FediVisibility.get
+        else:
+          if (entityBodyJson["status"]["visibility"]["level"].getInt <= 10):  statusVisibility = PostDirect
+          if (entityBodyJson["status"]["visibility"]["level"].getInt <= 60):  statusVisibility = PostPrivate
+          if (entityBodyJson["status"]["visibility"]["level"].getInt == 100): statusVisibility = PostUnlisted
+
+
+        let post = await fediPost(config, statusText, statusVisibility)
+
+        if post["responseCode"].getStr != "200 OK":
+          response = "[fedi-create-post] error: " & post["error"].getStr
+        else:
+          state["checkin_id"] = entityBodyJson["status"]["fromStation"]["scheduledTime"]
+          state["to_station"] = entityBodyJson["status"]["toStation"]["name"]
+          state["post_id"]    = post["id"]
+          state["post_time"]  = newJInt(toUnix(getTime()))
+          state["comment"]    = entityBodyJson["status"]["comment"]
+          saveState(state)
+          response = "[fedi-create-post] created post " & $post["id"] & ": " & $post["content"]
+
+
+        await req.respond(responseCode, "[checkin] " & response, headers.newHttpHeaders())
+
+
+      of "undo":
+        var response:     string
+        var responseCode: HttpCode = Http200
+
+        if state.hasKey("post_id") and state.hasKey("post_time"):
+          let currentTime = toUnix(getTime())
+
+          if currentTime - state["post_time"].getInt < 15*60:
+            let post = await fediDelete(config, state["post_id"].getStr)
+
+            if post["responseCode"].getStr != "200 OK":
+              responseCode = Http500
+              response = "[fedi-delete-post] error: " & post["error"].getStr
+            else:
+              state = newJObject()
+              saveState(state)
+              response = "[fedi-delete-post] successfully deleted post " & $post["id"]
+
+          else:
+            response = "Post too old!"
+
+        else:
+          response = "Nothing posted yet!"
+
+        await req.respond(responseCode, "[undo] " & response, headers.newHttpHeaders())
+
+      else:
+        await req.respond(Http400, "Invalid request", headers.newHttpHeaders())
+        return
+
+
+  except:
+    let
+      e   = getCurrentException()
+      msg = getCurrentExceptionMsg()
+
+    echo "Got exception ", repr(e), " with message ", msg
+
+    await req.respond(Http500, "Internal server error, whoops", headers.newHttpHeaders())
+    return
diff --git a/src/threadvars.nim b/src/threadvars.nim
@@ -0,0 +1,8 @@
+import std/[json]
+
+import types
+
+var storagePath* {.threadvar.} : string
+var config*      {.threadvar.} : Config
+var state*       {.threadvar.} : JsonNode
+
diff --git a/src/travelynx2fedi.nim b/src/travelynx2fedi.nim
@@ -0,0 +1,68 @@
+import std/[asyncdispatch, asynchttpserver]
+import std/nativesockets
+import std/[json, parseutils, strutils]
+import std/os
+
+import threadvars, types, utils, requestHandler
+
+proc main {.async.} =
+  proc CtrlCHook() {.noconv.} =
+    echo "Ctrl+C fired! \nStopping Server now!"
+    quit()
+
+  proc callback(req: Request) {.async.} =
+    await requestHandler(req)
+
+  try:
+    setControlCHook(CtrlCHook)
+  
+    var configPath = "/var/lib/travelynx2fedi/config.ini"
+    storagePath    = "/var/lib/travelynx2fedi"
+
+    if existsEnv("TRAVELYNX2FEDI_STORAGE_PATH"):
+      storagePath = getEnv("TRAVELYNX2FEDI_STORAGE_PATH")
+
+    if existsEnv("TRAVELYNX2FEDI_CONFIG_PATH"):
+      configPath = getEnv("TRAVELYNX2FEDI_CONFIG_PATH")
+
+    if not dirExists(storagePath):
+      echo "The storage path '" & storagePath & "' doesn't exist!"
+      quit(1)
+
+    if not fileExists(configPath):
+      echo "The config file '" & configPath & "' doesn't exist!"
+      quit(1)
+
+    if not fileExists(storagePath & "/state.json"):
+      writeFile(storagePath & "/state.json", "{}")
+
+    config = parseConfig(configPath)
+    state  = parseFile(storagePath & "/state.json")
+
+    let server = newAsyncHttpServer()
+    var port   = config.ServerPort
+
+    if existsEnv("TRAVELYNX2FEDI_PORT"):
+      port = Port(getEnv("TRAVELYNX2FEDI_PORT").parseInt)
+
+    server.listen(Port(port), "::1", AF_INET6)
+
+    echo "Started webserver on port " & $server.getPort
+
+    while true:
+      if server.shouldAcceptRequest():
+        await server.acceptRequest(callback)
+      else:
+        await sleepAsync(500)
+
+  except:
+    let
+      e   = getCurrentException()
+      msg = getCurrentExceptionMsg()
+
+    echo "Got exception ", repr(e), " with message ", msg
+
+    echo "error happend, meh."
+    quit(1)
+
+waitFor main()+
\ No newline at end of file
diff --git a/src/types.nim b/src/types.nim
@@ -0,0 +1,17 @@
+import std/options
+import std/nativesockets
+
+type
+  PostVisibility* = enum
+    PostDirect = "direct", PostPrivate = "private",
+    PostUnlisted = "unlisted", PostPublic = "public"
+
+  Config* = object
+    ServerPort*        : Port
+    ServerAccessToken* : Option[string]
+    TravelynxUsername* : Option[string]
+    FediURL*           : string
+    FediAccessToken*   : string
+    FediVisibility*    : Option[PostVisibility]
+    FediSpoilerText*   : Option[string]
+    FediUseMarkdown*   : bool
diff --git a/src/utils.nim b/src/utils.nim
@@ -0,0 +1,50 @@
+import std/[nativesockets, options]
+import std/[parsecfg, parseutils, strutils]
+
+import threadvars, types
+
+proc parseConfig* (configFile: string): types.Config =
+  try:
+    let config = loadConfig(configFile)
+
+    if config.getSectionValue("server", "Port") != "":
+      result.ServerPort = Port(config.getSectionValue("server", "Port").parseInt)
+    else:
+      result.ServerPort = Port(8080)
+
+    if config.getSectionValue("server", "accessToken") != "":
+      result.ServerAccessToken = some(config.getSectionValue("server", "accessToken"))
+
+    if config.getSectionValue("travelynx", "username") != "":
+      result.TravelynxuserName = some(config.getSectionValue("travelynx", "username"))
+
+    if config.getSectionValue("fedi", "url") != "":
+      result.FediURL = config.getSectionValue("fedi", "url")
+    else:
+      echo "Configparser: [fedi]url not specified!"
+      quit(1)
+
+    if config.getSectionValue("fedi", "accessToken") != "":
+      result.FediAccessToken = config.getSectionValue("fedi", "accessToken")
+    else:
+      echo "Configparser: [fedi]accessToken not specified!"
+      quit(1)
+
+    if config.getSectionValue("fedi", "visibility") != "":
+      result.FediVisibility = some(parseEnum[PostVisibility](config.getSectionValue("fedi", "visibility")))
+
+    if config.getSectionValue("fedi", "spoilerText") != "":
+      result.FediSpoilerText = some(config.getSectionValue("fedi", "spoilerText"))
+
+    if config.getSectionValue("fedi", "useMarkdown") != "":
+      result.FediUseMarkdown = config.getSectionValue("fedi", "useMarkdown").parseBool
+    else:
+      result.FediUseMarkdown = false
+
+  except CatchableError:
+    let
+      e   = getCurrentException()
+      msg = getCurrentExceptionMsg()
+
+    echo "Couldn't parse config file:"
+    echo "Got exception ", repr(e), " with message ", msg
diff --git a/travelynx2fedi.nimble b/travelynx2fedi.nimble
@@ -0,0 +1,13 @@
+# Package
+
+version       = "0.1.0"
+author        = "Leah (ctucx)"
+description   = "Automaticly post travelynx checkins on the fediverse"
+license       = "MIT"
+srcDir        = "src"
+bin           = @["travelynx2fedi"]
+
+
+# Dependencies
+
+requires "nim >= 1.6.12"