~jan0sch/smederee

Showing details for patch 078d0d60bff85cbadb1163fea15ae3887215d4e4.
2023-05-25 (Thu), 6:08 PM - Jens Grassel - 078d0d60bff85cbadb1163fea15ae3887215d4e4

Cleanup and CSRF workarounds.

- prepare the build configuration for the future ticket service standalone
  deployment
- move `MarkdownRenderer` into the html module
- make the CSRF Origin check function accept a non empty list of safe
  origins and check if at least one is matching
Summary of changes
1 files added
  • modules/html-utils/src/main/scala/de/smederee/html/MarkdownRenderer.scala
5 files modified with 105 lines added and 22 lines removed
  • build.sbt with 88 added and 13 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/HubServer.scala with 10 added and 4 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala with 3 added and 2 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala with 3 added and 2 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryFiles.scala.html with 1 added and 1 removed lines
1 files removed
  • modules/hub/src/main/scala/de/smederee/hub/MarkdownRenderer.scala
diff -rN -u old-smederee/build.sbt new-smederee/build.sbt
--- old-smederee/build.sbt	2025-01-16 08:07:45.415306550 +0000
+++ new-smederee/build.sbt	2025-01-16 08:07:45.419306557 +0000
@@ -139,6 +139,10 @@
       version := "0.7.0-SNAPSHOT",
       libraryDependencies ++= Seq(
         library.catsCore,
+        library.commonMark,
+        library.commonMarkExtHeadingAnchor,
+        library.commonMarkExtTables,
+        library.commonMarkExtTaskListItems,
         library.http4sCore,
         library.ip4sCore,
         library.munit             % Test,
@@ -180,10 +184,6 @@
         library.circeCore,
         library.circeGeneric,
         library.circeParser,
-        library.commonMark,
-        library.commonMarkExtHeadingAnchor,
-        library.commonMarkExtTables,
-        library.commonMarkExtTaskListItems,
         library.doobieCore,
         library.doobieHikari,
         library.doobiePostgres,
@@ -230,7 +230,7 @@
       Seq(
         daemonUser := "smederee",
         daemonGroup := "smederee",
-        Debian / debianPackageProvides += "smederee",
+        Debian / debianPackageProvides += "smederee-hub",
         Debian / debianPackageDependencies += "openjdk-17-jre-headless",
         defaultLinuxInstallLocation := "/opt",
         maintainer := "Wegtam GmbH <devops@wegtam.com>",
@@ -258,8 +258,8 @@
         Rpm / maintainerScripts := maintainerScriptsAppend((Rpm / maintainerScripts).value)(
           RpmConstants.Post -> s"restartService ${normalizedName.value}"
         ),
-        packageSummary := "Smederee - Software collaboration platform.",
-        packageDescription := "Leverage the power of the darcs vcs to handle your projects with ease and confidence",
+        packageSummary := "Smederee Hub Service - Software collaboration platform.",
+        packageDescription := "Leverage the power of the darcs vcs to handle your projects with ease and confidence, this is the central hub service",
         Debian / requiredStartFacilities := Option("$local_fs $remote_fs $network $postgresql"),
         // Do not package API docs.
         Compile / packageDoc / publishArtifact := false,
@@ -324,8 +324,16 @@
 lazy val tickets =
   project
     .in(file("modules/tickets"))
-    .dependsOn(email, htmlUtils, i18n, security)
-    .enablePlugins(AutomateHeaderPlugin)
+    .dependsOn(email, htmlUtils, i18n, security, twirl)
+    .enablePlugins(
+      AutomateHeaderPlugin,
+      DebianPlugin,
+      JavaServerAppPackaging,
+      JDebPackaging,
+      RpmPlugin,
+      SbtTwirl,
+      SystemdPlugin
+    )
     .configs(IntegrationTest)
     .settings(commonSettings)
     .settings(
@@ -342,10 +350,6 @@
         library.circeCore,
         library.circeGeneric,
         library.circeParser,
-        library.commonMark,
-        library.commonMarkExtHeadingAnchor,
-        library.commonMarkExtTables,
-        library.commonMarkExtTaskListItems,
         library.doobieCore,
         library.doobieHikari,
         library.doobiePostgres,
@@ -372,6 +376,77 @@
         library.scalaCheck        % Test
       )
     )
+    .settings(
+      libraryDependencies := libraryDependencies.value.map {
+        case module if module.name == "twirl-api" =>
+          module.cross(CrossVersion.for3Use2_13).exclude("org.scala-lang.modules", "scala-xml_2.13")
+        case module => module
+      } ++ Seq("org.scala-lang.modules" %% "scala-xml" % "2.1.0"),
+      TwirlKeys.templateImports ++= Seq(
+        "cats._",
+        "cats.data._",
+        "cats.syntax.all._",
+        "de.smederee.html._",
+        "de.smederee.i18n._",
+        "de.smederee.security.{ CsrfToken, UserId, Username }",
+        "org.http4s.Uri"
+      )
+    )
+    .settings(
+      Seq(
+        daemonUser := "smederee",
+        daemonGroup := "smederee",
+        Debian / debianPackageProvides += "smederee-tickets",
+        Debian / debianPackageDependencies += "openjdk-17-jre-headless",
+        defaultLinuxInstallLocation := "/opt",
+        maintainer := "Wegtam GmbH <devops@wegtam.com>",
+        rpmLicense := Option("AGPL-3.0 or later"),
+        rpmVendor := "Wegtam GmbH <devops@wegtam.com>",
+        // Create an empty `conf/production.conf` file if it does not exist.
+        Debian / maintainerScripts := maintainerScriptsAppend((Debian / maintainerScripts).value)(
+          DebianConstants.Postinst -> Seq(
+            s"touch ${defaultLinuxInstallLocation.value}/${normalizedName.value}/conf/production.conf",
+            s"chown ${daemonUser.value}:${daemonGroup.value} ${defaultLinuxInstallLocation.value}/${normalizedName.value}/conf/production.conf"
+          ).mkString(" && ") // Chain both commands together in the shell.
+        ),
+        // Require a service restart after installation / update.
+        Debian / maintainerScripts := maintainerScriptsAppend((Debian / maintainerScripts).value)(
+          DebianConstants.Postinst -> s"restartService ${normalizedName.value}"
+        ),
+        // Create an empty `conf/production.conf` file if it does not exist.
+        Rpm / maintainerScripts := maintainerScriptsAppend((Rpm / maintainerScripts).value)(
+          RpmConstants.Post -> Seq(
+            s"touch ${defaultLinuxInstallLocation.value}/${normalizedName.value}/conf/production.conf",
+            s"chown ${daemonUser.value}:${daemonGroup.value} ${defaultLinuxInstallLocation.value}/${normalizedName.value}/conf/production.conf"
+          ).mkString(" && ") // Chain both commands together in the shell.
+        ),
+        // Require a service restart after installation / update.
+        Rpm / maintainerScripts := maintainerScriptsAppend((Rpm / maintainerScripts).value)(
+          RpmConstants.Post -> s"restartService ${normalizedName.value}"
+        ),
+        packageSummary := "Smederee Ticket Service - Software collaboration platform.",
+        packageDescription := "Leverage the power of the darcs vcs to handle your projects with ease and confidence, this is the ticket service",
+        Debian / requiredStartFacilities := Option("$local_fs $remote_fs $network $postgresql"),
+        // Do not package API docs.
+        Compile / packageDoc / publishArtifact := false,
+        Compile / doc / sources := Seq.empty,
+        // Require tests to be run before building a debian package.
+        Debian / packageBin := ((Debian / packageBin) dependsOn (Test / test) dependsOn (IntegrationTest / test)).value,
+        // Require tests to be run before building a RPM package.
+        Rpm / packageBin := ((Rpm / packageBin) dependsOn (Test / test) dependsOn (IntegrationTest / test)).value,
+        // Require tests to be run before building a universal package.
+        Universal / packageBin := ((Universal / packageBin) dependsOn (Test / test) dependsOn (IntegrationTest / test)).value,
+        Universal / packageOsxDmg := ((Universal / packageOsxDmg) dependsOn (Test / test) dependsOn (IntegrationTest / test)).value,
+        Universal / packageXzTarball := ((Universal / packageXzTarball) dependsOn (Test / test) dependsOn (IntegrationTest / test)).value,
+        Universal / packageZipTarball := ((Universal / packageZipTarball) dependsOn (Test / test) dependsOn (IntegrationTest / test)).value,
+        // Prevent a customised local application.conf file to be packaged!
+        Compile / packageBin / mappings ~= { files =>
+          files.filterNot {
+            case (_, name) => name == "application.conf"
+          }
+        }
+      )
+    )
 
 // FIXME: This is a workaround until http4s-twirl gets published properly for Scala 3!
 lazy val twirl =
diff -rN -u old-smederee/modules/html-utils/src/main/scala/de/smederee/html/MarkdownRenderer.scala new-smederee/modules/html-utils/src/main/scala/de/smederee/html/MarkdownRenderer.scala
--- old-smederee/modules/html-utils/src/main/scala/de/smederee/html/MarkdownRenderer.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/html-utils/src/main/scala/de/smederee/html/MarkdownRenderer.scala	2025-01-16 08:07:45.419306557 +0000
@@ -0,0 +1,184 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.html
+
+import java.util.Locale
+
+import cats.syntax.all._
+import org.commonmark.ext.gfm.tables.TablesExtension
+import org.commonmark.ext.heading.anchor.HeadingAnchorExtension
+import org.commonmark.ext.task.list.items.TaskListItemsExtension
+import org.commonmark.node._
+import org.commonmark.parser.Parser
+import org.commonmark.renderer.NodeRenderer
+import org.commonmark.renderer.html._
+import org.http4s.Uri
+import org.slf4j.{ Logger, LoggerFactory }
+
+import scala.annotation.nowarn
+import scala.jdk.CollectionConverters._
+import scala.util.matching.Regex
+
+object MarkdownRenderer {
+  private val log = LoggerFactory.getLogger(getClass())
+
+  private val MarkdownExtensions =
+    List(TablesExtension.create(), HeadingAnchorExtension.create(), TaskListItemsExtension.create())
+
+  /** Render the given ticket content using markdown.
+    *
+    * @param markdownSource
+    *   The content of a ticket description.
+    * @return
+    *   A string containing the rendered markdown (HTML).
+    */
+  def renderTicketContent(markdownSource: String): String = {
+    val parser   = Parser.builder().extensions(MarkdownExtensions.asJava).build()
+    val markdown = parser.parse(markdownSource)
+    val renderer = HtmlRenderer
+      .builder()
+      .escapeHtml(true)
+      .extensions(MarkdownExtensions.asJava)
+      .sanitizeUrls(true)
+      .build()
+    renderer.render(markdown)
+  }
+
+  /** Render the given markdown sources and adjust all relative links by prefixing them with the path for the repostiory
+    * file browsing (`repo-name/files`).
+    *
+    * @param repositoryName
+    *   The name of the repository which contains the markdown sources. If the option is empty then no URI path prefix
+    *   will be set.
+    * @param markdownSource
+    *   A string containing the markdown sources to be rendered (usually the content of the README.md file in the
+    *   repository root).
+    * @return
+    *   A string containing the rendered markdown (HTML).
+    */
+  def renderRepositoryMarkdownFile(repositoryName: Option[String])(markdownSource: String): String = {
+    val parser   = Parser.builder().extensions(MarkdownExtensions.asJava).build()
+    val markdown = parser.parse(markdownSource)
+    val renderer = HtmlRenderer
+      .builder()
+      .attributeProviderFactory(new AttributeProviderFactory {
+        override def create(context: AttributeProviderContext): AttributeProvider =
+          new LinkHrefCorrector(repositoryName)
+      })
+      .escapeHtml(true)
+      .extensions(MarkdownExtensions.asJava)
+      .nodeRendererFactory(new HtmlNodeRendererFactory {
+        override def create(context: HtmlNodeRendererContext): NodeRenderer = new ToDoTextRenderer(context)
+      })
+      .sanitizeUrls(true)
+      .build()
+    renderer.render(markdown)
+  }
+
+  /** A helper class used by the `renderRepositoryOverviewReadme` function to adjust the `href` attribute of links that
+    * are not absolute.
+    *
+    * @param repositoryName
+    *   The name of the repository which contains the markdown sources. If the option is empty then no URI path prefix
+    *   will be set.
+    */
+  @SuppressWarnings(
+    Array("scalafix:DisableSyntax.isInstanceOf")
+  ) // TODO: Find a way to get rid of the isInstanceOf below.
+  @nowarn("msg=discarded non-Unit value.*")
+  class LinkHrefCorrector(private val repositoryName: Option[String]) extends AttributeProvider {
+    override def setAttributes(node: Node, tagName: String, attributes: java.util.Map[String, String]): Unit =
+      if (node.isInstanceOf[Link]) {
+        (repositoryName, attributes.asScala.get("href").flatMap(href => Uri.fromString(href).toOption)).mapN {
+          case (repositoryName, uri) =>
+            if (uri.scheme.isEmpty) {
+              val pathPrefix = Uri.Path(Vector(Uri.Path.Segment(repositoryName), Uri.Path.Segment("files")))
+              val correctedUri =
+                if (uri.path.startsWith(pathPrefix))
+                  uri
+                else
+                  uri.copy(path = pathPrefix |+| uri.path)
+              log.debug(s"Corrected URI for repository overview README rendering: $uri -> $correctedUri")
+              attributes.put("href", correctedUri.toString)
+            }
+        }
+      }
+  }
+
+  /** A custom text node renderer which is supposed to highlight several words which are considered "todo items". This
+    * is currently a very limited approach with a fixed list of matching words.
+    *
+    * @param context
+    *   A context for an html node renderer that is needed to extract the html writer from it.
+    */
+  class ToDoTextRenderer(context: HtmlNodeRendererContext) extends NodeRenderer {
+    import ToDoTextCssMapping._
+
+    private final val htmlWriter: HtmlWriter = context.getWriter()
+    private final val log: Logger            = LoggerFactory.getLogger(getClass())
+
+    override def getNodeTypes(): java.util.Set[Class[? <: Node]] = Set(classOf[Text]).asJava
+
+    @SuppressWarnings(Array("scalafix:DisableSyntax.null", "scalafix:DisableSyntax.asInstanceOf"))
+    override def render(node: Node): Unit = {
+      val text = node.asInstanceOf[Text] // We only receive text nodes (see `getNodeTypes`).
+      isToDoItem.findFirstMatchIn(text.getLiteral()) match {
+        case Some(matchedItem) =>
+          log.debug(s"Matched TODO item: ${text.getLiteral()} (${matchedItem.groupCount})")
+          if (matchedItem.group(4) === null) {
+            val prefix   = matchedItem.group(1)
+            val item     = matchedItem.group(2)
+            val suffix   = matchedItem.group(3)
+            val cssClass = todoCssClasses(item.toLowerCase(Locale.ROOT))
+            htmlWriter.text(prefix)
+            htmlWriter.tag("span", Map("class" -> cssClass).asJava)
+            htmlWriter.text(item + ":")
+            htmlWriter.tag("/span")
+            htmlWriter.text(suffix)
+          } else {
+            val item     = matchedItem.group(4)
+            val cssClass = todoCssClasses(item.toLowerCase(Locale.ROOT))
+            htmlWriter.tag("span", Map("class" -> cssClass).asJava)
+            htmlWriter.text(item)
+            htmlWriter.tag("/span")
+          }
+        case _ =>
+          htmlWriter.text(text.getLiteral())
+      }
+    }
+
+  }
+}
+
+object ToDoTextCssMapping {
+  /* We want to match either on specific words followed by a colon (`:`) and also on complete text nodes
+   * which can be created if markdown rendering is involved.
+   * For example a `**WORD** some text` will produce two text nodes (`WORD` and ` some text`)
+   * while `WORD: some text` will only produce one.
+   */
+  val isToDoItem: Regex = "(?i)(.*)(DEBUG|FIXME|HACK|TODO):(.*)|^(DEBUG|FIXME|HACK|TODO)$".r
+
+  // Mapping of todo item (words) to css classes for highlighting.
+  val todoCssClasses: Map[String, String] = Map(
+    "todo"  -> "todo-default",
+    "fixme" -> "todo-error",
+    "debug" -> "todo-info",
+    "hack"  -> "todo-warning"
+  ).withDefaultValue("todo-default")
+
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2025-01-16 08:07:45.415306550 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2025-01-16 08:07:45.419306557 +0000
@@ -23,6 +23,7 @@
 import java.util.Locale
 
 import cats.arrow.FunctionK
+import cats.data._
 import cats.effect._
 import cats.syntax.all._
 import com.typesafe.config._
@@ -62,8 +63,11 @@
     * @return
     *   A function which will check the correct origin of requests / cookies inside the CSRF middleware.
     */
-  private def createCsrfOriginCheck(linkConfig: ExternalUrlConfiguration): Request[IO] => Boolean = { request =>
-    CSRF.defaultOriginCheck(request, linkConfig.host.toString, linkConfig.scheme, linkConfig.port.map(_.value))
+  private def createCsrfOriginCheck(allowedOrigins: NonEmptyList[ExternalUrlConfiguration]): Request[IO] => Boolean = {
+    request =>
+      allowedOrigins.exists { linkConfig =>
+        CSRF.defaultOriginCheck(request, linkConfig.host.toString, linkConfig.scheme, linkConfig.port.map(_.value))
+      }
   }
 
   /** Try to load the CSRF key from the given path. If it doesn't exist or fails then a new key is generated and stored
@@ -171,8 +175,10 @@
       ticketMilestoneRoutes = new MilestoneRoutes[IO](ticketsConfiguration, ticketMilestonesRepo, ticketProjectsRepo)
       cryptoClock           = java.time.Clock.systemUTC
       csrfKey <- loadOrCreateCsrfKey(hubConfiguration.service.csrfKeyFile)
-      csrfOriginCheck = createCsrfOriginCheck(hubConfiguration.service.external)
-      csrfBuilder     = CSRF[IO, IO](csrfKey, csrfOriginCheck)
+      csrfOriginCheck = createCsrfOriginCheck(
+        NonEmptyList(hubConfiguration.service.external, List(ticketsConfiguration.externalUrl))
+      )
+      csrfBuilder = CSRF[IO, IO](csrfKey, csrfOriginCheck)
       /* The idea behind the `onFailure` part of the CSRF protection middleware is
        * that we simply remove the CSRF cookie and redirect the user to the frontpage.
        * This is done to avoid frustration for users after a server restart because
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/MarkdownRenderer.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/MarkdownRenderer.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/MarkdownRenderer.scala	2025-01-16 08:07:45.415306550 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/MarkdownRenderer.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,182 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.hub
-
-import java.util.Locale
-
-import cats.syntax.all._
-import de.smederee.tickets.TicketContent
-import org.commonmark.ext.gfm.tables.TablesExtension
-import org.commonmark.ext.heading.anchor.HeadingAnchorExtension
-import org.commonmark.ext.task.list.items.TaskListItemsExtension
-import org.commonmark.node._
-import org.commonmark.parser.Parser
-import org.commonmark.renderer.NodeRenderer
-import org.commonmark.renderer.html._
-import org.http4s.Uri
-import org.slf4j.{ Logger, LoggerFactory }
-
-import scala.annotation.nowarn
-import scala.jdk.CollectionConverters._
-import scala.util.matching.Regex
-
-object MarkdownRenderer {
-  private val log = LoggerFactory.getLogger(getClass())
-
-  private val MarkdownExtensions =
-    List(TablesExtension.create(), HeadingAnchorExtension.create(), TaskListItemsExtension.create())
-
-  /** Render the given ticket content using markdown.
-    *
-    * @param markdownSource
-    *   The content of a ticket description.
-    * @return
-    *   A string containing the rendered markdown (HTML).
-    */
-  def renderTicketContent(markdownSource: TicketContent): String = {
-    val parser   = Parser.builder().extensions(MarkdownExtensions.asJava).build()
-    val markdown = parser.parse(markdownSource.toString)
-    val renderer = HtmlRenderer
-      .builder()
-      .escapeHtml(true)
-      .extensions(MarkdownExtensions.asJava)
-      .sanitizeUrls(true)
-      .build()
-    renderer.render(markdown)
-  }
-
-  /** Render the given markdown sources and adjust all relative links by prefixing them with the path for the repostiory
-    * file browsing (`repo-name/files`).
-    *
-    * @param repo
-    *   The repository which contains the markdown sources. If the option is empty then no URI path prefix will be set.
-    * @param markdownSource
-    *   A string containing the markdown sources to be rendered (usually the content of the README.md file in the
-    *   repository root).
-    * @return
-    *   A string containing the rendered markdown (HTML).
-    */
-  def renderRepositoryMarkdownFile(repo: Option[VcsRepository])(markdownSource: String): String = {
-    val parser   = Parser.builder().extensions(MarkdownExtensions.asJava).build()
-    val markdown = parser.parse(markdownSource)
-    val renderer = HtmlRenderer
-      .builder()
-      .attributeProviderFactory(new AttributeProviderFactory {
-        override def create(context: AttributeProviderContext): AttributeProvider = new LinkHrefCorrector(repo)
-      })
-      .escapeHtml(true)
-      .extensions(MarkdownExtensions.asJava)
-      .nodeRendererFactory(new HtmlNodeRendererFactory {
-        override def create(context: HtmlNodeRendererContext): NodeRenderer = new ToDoTextRenderer(context)
-      })
-      .sanitizeUrls(true)
-      .build()
-    renderer.render(markdown)
-  }
-
-  /** A helper class used by the `renderRepositoryOverviewReadme` function to adjust the `href` attribute of links that
-    * are not absolute.
-    *
-    * @param repo
-    *   The repository which contains the markdown sources. If the option is empty then no URI path prefix will be set.
-    */
-  @SuppressWarnings(
-    Array("scalafix:DisableSyntax.isInstanceOf")
-  ) // TODO: Find a way to get rid of the isInstanceOf below.
-  @nowarn("msg=discarded non-Unit value.*")
-  class LinkHrefCorrector(private val repo: Option[VcsRepository]) extends AttributeProvider {
-    override def setAttributes(node: Node, tagName: String, attributes: java.util.Map[String, String]): Unit =
-      if (node.isInstanceOf[Link]) {
-        (repo, attributes.asScala.get("href").flatMap(href => Uri.fromString(href).toOption)).mapN {
-          case (repository, uri) =>
-            if (uri.scheme.isEmpty) {
-              val pathPrefix = Uri.Path(Vector(Uri.Path.Segment(repository.name.toString), Uri.Path.Segment("files")))
-              val correctedUri =
-                if (uri.path.startsWith(pathPrefix))
-                  uri
-                else
-                  uri.copy(path = pathPrefix |+| uri.path)
-              log.debug(s"Corrected URI for repository overview README rendering: $uri -> $correctedUri")
-              attributes.put("href", correctedUri.toString)
-            }
-        }
-      }
-  }
-
-  /** A custom text node renderer which is supposed to highlight several words which are considered "todo items". This
-    * is currently a very limited approach with a fixed list of matching words.
-    *
-    * @param context
-    *   A context for an html node renderer that is needed to extract the html writer from it.
-    */
-  class ToDoTextRenderer(context: HtmlNodeRendererContext) extends NodeRenderer {
-    import ToDoTextCssMapping._
-
-    private final val htmlWriter: HtmlWriter = context.getWriter()
-    private final val log: Logger            = LoggerFactory.getLogger(getClass())
-
-    override def getNodeTypes(): java.util.Set[Class[? <: Node]] = Set(classOf[Text]).asJava
-
-    @SuppressWarnings(Array("scalafix:DisableSyntax.null", "scalafix:DisableSyntax.asInstanceOf"))
-    override def render(node: Node): Unit = {
-      val text = node.asInstanceOf[Text] // We only receive text nodes (see `getNodeTypes`).
-      isToDoItem.findFirstMatchIn(text.getLiteral()) match {
-        case Some(matchedItem) =>
-          log.debug(s"Matched TODO item: ${text.getLiteral()} (${matchedItem.groupCount})")
-          if (matchedItem.group(4) === null) {
-            val prefix   = matchedItem.group(1)
-            val item     = matchedItem.group(2)
-            val suffix   = matchedItem.group(3)
-            val cssClass = todoCssClasses(item.toLowerCase(Locale.ROOT))
-            htmlWriter.text(prefix)
-            htmlWriter.tag("span", Map("class" -> cssClass).asJava)
-            htmlWriter.text(item + ":")
-            htmlWriter.tag("/span")
-            htmlWriter.text(suffix)
-          } else {
-            val item     = matchedItem.group(4)
-            val cssClass = todoCssClasses(item.toLowerCase(Locale.ROOT))
-            htmlWriter.tag("span", Map("class" -> cssClass).asJava)
-            htmlWriter.text(item)
-            htmlWriter.tag("/span")
-          }
-        case _ =>
-          htmlWriter.text(text.getLiteral())
-      }
-    }
-
-  }
-}
-
-object ToDoTextCssMapping {
-  /* We want to match either on specific words followed by a colon (`:`) and also on complete text nodes
-   * which can be created if markdown rendering is involved.
-   * For example a `**WORD** some text` will produce two text nodes (`WORD` and ` some text`)
-   * while `WORD: some text` will only produce one.
-   */
-  val isToDoItem: Regex = "(?i)(.*)(DEBUG|FIXME|HACK|TODO):(.*)|^(DEBUG|FIXME|HACK|TODO)$".r
-
-  // Mapping of todo item (words) to css classes for highlighting.
-  val todoCssClasses: Map[String, String] = Map(
-    "todo"  -> "todo-default",
-    "fixme" -> "todo-error",
-    "debug" -> "todo-info",
-    "hack"  -> "todo-warning"
-  ).withDefaultValue("todo-default")
-
-}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala	2025-01-16 08:07:45.415306550 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala	2025-01-16 08:07:45.419306557 +0000
@@ -27,8 +27,9 @@
 import cats.syntax.all._
 import de.smederee.darcs._
 import de.smederee.html.LinkTools._
-import de.smederee.hub.RequestHelpers.instances.given
+import de.smederee.html.MarkdownRenderer
 import de.smederee.hub.RelatedTypesConverter.given
+import de.smederee.hub.RequestHelpers.instances.given
 import de.smederee.hub.config._
 import de.smederee.hub.forms.types.FormErrors
 import de.smederee.i18n.LanguageCode
@@ -410,7 +411,7 @@
         case Some((lines, Some(filename))) =>
           if (filename.matches("(?iu).*\\.(md|markdown)$")) {
             Sync[F]
-              .delay(MarkdownRenderer.renderRepositoryMarkdownFile(repo)(lines.mkString("\n")))
+              .delay(MarkdownRenderer.renderRepositoryMarkdownFile(repo.map(_.name.toString))(lines.mkString("\n")))
               .map(_.some)
           } else {
             Sync[F].delay(lines.mkString("\n").some)
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala	2025-01-16 08:07:45.415306550 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala	2025-01-16 08:07:45.419306557 +0000
@@ -36,7 +36,6 @@
 import org.http4s.headers.Location
 import org.http4s.twirl.TwirlInstances._
 import org.slf4j.LoggerFactory
-import de.smederee.hub.MarkdownRenderer
 
 /** Routes for managing tickets.
   *
@@ -143,7 +142,9 @@
                 )
               )
             )
-            renderedTicketContent <- Sync[F].delay(ticket.content.map(MarkdownRenderer.renderTicketContent))
+            renderedTicketContent <- Sync[F].delay(
+              ticket.content.map(_.toString).map(MarkdownRenderer.renderTicketContent)
+            )
             resp <- Ok(
               views.html.showTicket(lang = language)(
                 projectBaseUri.addSegment("tickets"),
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryFiles.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryFiles.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryFiles.scala.html	2025-01-16 08:07:45.415306550 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryFiles.scala.html	2025-01-16 08:07:45.419306557 +0000
@@ -1,5 +1,5 @@
 @import java.util.Locale
-@import de.smederee.hub.ToDoTextCssMapping._
+@import de.smederee.html.ToDoTextCssMapping._
 @import de.smederee.hub._
 
 @(baseUri: Uri,