commit 7304c23e6aa58a9e243d79fd1a01f759b6b5ba88
Author: Leah (ctucx) <git@ctu.cx>
Date: Tue, 6 Jun 2023 16:51:48 +0200
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
|
177
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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"