~jan0sch/smederee

Showing details for patch 4e4251a937b2899ccaceaa74bc29f9b7ded65547.
2023-06-17 (Sat), 3:49 PM - Jens Grassel - 4e4251a937b2899ccaceaa74bc29f9b7ded65547

Hub: Add basic administrative commands for the cli.

- change hub service to start as before if no arguments are given
- print out help if `help`, `--help` or `-h` is given as argument
- add basic commands for user administration
    - delete a locked(!) user account by name
	- find an unlocked(!) user account either by email or name
	- list all unlocked(!) user accounts
	- lock an unlocked user account by name
	- unlock a locked user account by name

This is still somewhat crude but it works for now. :-)
Summary of changes
1 files modified with 389 lines added and 186 lines removed
  • modules/hub/src/main/scala/de/smederee/hub/HubServer.scala with 389 added and 186 removed lines
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 02:50:40.458308641 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2025-01-16 02:50:40.458308641 +0000
@@ -28,7 +28,7 @@
 import cats.syntax.all._
 import com.typesafe.config._
 import de.smederee.darcs._
-import de.smederee.email.SimpleJavaMailMiddleware
+import de.smederee.email.{ EmailAddress, SimpleJavaMailMiddleware }
 import de.smederee.html.LinkTools._
 import de.smederee.html._
 import de.smederee.hub.config._
@@ -47,6 +47,59 @@
 import pureconfig._
 import scodec.bits.ByteVector
 
+/** Commands that may be passed to the hub server to trigger specific behaviour.
+  *
+  * @param help
+  *   A string containing basic help information for the command.
+  * @param subcommands
+  *   A list of subcommands available.
+  */
+enum HubCommand(val help: String, val subcommands: List[HubSubcommand]) {
+  case user extends HubCommand(help = "Manage user accounts.", subcommands = List(HubSubcommand.list))
+}
+
+/** Subcommands that are grouped below a [[HubCommand]].
+  *
+  * @param arguments
+  *   A list of possible arguments for the subcommand.
+  * @param help
+  *   A string containing basic help information for the subcommand.
+  */
+enum HubSubcommand(val arguments: List[HubArgument], val help: String) {
+  case delete
+      extends HubSubcommand(
+        arguments = List(HubArgument.DryRun, HubArgument.Username),
+        help = "Delete the account from the database. This is not reversible!"
+      )
+  case find
+      extends HubSubcommand(
+        arguments = List(HubArgument.Email, HubArgument.Username),
+        help = "Find a user account in the database."
+      )
+  case list extends HubSubcommand(arguments = Nil, help = "List all accounts in the database.")
+  case lock
+      extends HubSubcommand(
+        arguments = List(HubArgument.DryRun, HubArgument.Username),
+        help = "Lock the account in the database."
+      )
+  case unlock
+      extends HubSubcommand(
+        arguments = List(HubArgument.DryRun, HubArgument.Username),
+        help = "Unlock the account in the database."
+      )
+}
+
+/** Possible arguments for commands and subcommands.
+  *
+  * @param help
+  *   A string containing basic help information for the subcommand.
+  */
+enum HubArgument(val help: String) {
+  case DryRun   extends HubArgument(help = "--dry-run")
+  case Email    extends HubArgument(help = "<email>")
+  case Username extends HubArgument(help = "<username>")
+}
+
 /** This is the main entry point for the hub service.
   *
   * It initialises the application (configuration parsing, database migrations) and starts the HTTP service eventually.
@@ -95,199 +148,349 @@
       _ <- IO(Files.setPosixFilePermissions(csrfKeyFile, PosixFilePermissions.fromString("rw-------")))
     } yield key
 
-  def run(args: List[String]): IO[ExitCode] = {
-    val _                       = Locale.setDefault(Locale.ENGLISH) // TODO: Make this configurable.
+  /** Run migrations on the databases in the given configurations and return their results.
+    *
+    * @param hubConfiguration
+    *   Configuration for the Smederee hub service.
+    * @param ticketsConfiguration
+    *   Configuration for the Smederee ticket service.
+    * @return
+    *   A tuple holding the migration results.
+    */
+  private def migrateDatabases(
+      hubConfiguration: SmedereeHubConfig,
+      ticketsConfiguration: SmedereeTicketsConfiguration
+  ) = {
     val hubDatabaseMigrator     = new de.smederee.hub.DatabaseMigrator[IO]
     val ticketsDatabaseMigrator = new de.smederee.tickets.config.DatabaseMigrator[IO]
     for {
-      hubConfiguration <- IO(
-        ConfigSource
-          .fromConfig(ConfigFactory.load(getClass.getClassLoader))
-          .at(SmedereeHubConfig.location)
-          .loadOrThrow[SmedereeHubConfig]
-      )
-      ticketsConfiguration <- IO(
-        ConfigSource
-          .fromConfig(ConfigFactory.load(getClass.getClassLoader))
-          .at(SmedereeTicketsConfiguration.location)
-          .loadOrThrow[SmedereeTicketsConfiguration]
-      )
-      _ <- IO {
-        val defaultSecret = "CHANGEME".getBytes(StandardCharsets.UTF_8)
-        if (java.util.Arrays.equals(defaultSecret, hubConfiguration.service.authentication.cookieSecret.toArray))
-          log.warn("SERVICE IS USING DEFAULT COOKIE SECRET! PLEASE CONFIGURE A SECURE ONE!")
-      }
-      repoCheck <- IO {
-        val repositoriesDirectory = hubConfiguration.service.darcs.repositoriesDirectory.toPath
-        if (Files.exists(repositoriesDirectory)) {
-          if (Files.isDirectory(repositoriesDirectory)) {
-            Right(s"Using repositories directory at: $repositoriesDirectory")
-          } else {
-            Left(s"Path to repositories directory exists but is not a directory: $repositoriesDirectory")
-          }
-        } else {
-          log.warn(s"Repositories directory does not exist, trying to create it: $repositoriesDirectory")
-          Files.createDirectories(
-            repositoriesDirectory,
-            PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwxr-x---"))
-          )
-          Right(s"Using repositories directory at: $repositoriesDirectory")
-        }
-      }
-      _ <- repoCheck match {
-        case Left(error)    => IO.raiseError(new RuntimeException(error))
-        case Right(message) => IO(log.info(message))
-      }
-      _ <- hubDatabaseMigrator.migrate(
+      hubMigrations <- hubDatabaseMigrator.migrate(
         hubConfiguration.database.url,
         hubConfiguration.database.user,
         hubConfiguration.database.pass
       )
-      _ <- ticketsDatabaseMigrator.migrate(
+      ticketMigrations <- ticketsDatabaseMigrator.migrate(
         ticketsConfiguration.database.url,
         ticketsConfiguration.database.user,
         ticketsConfiguration.database.pass
       )
-      hubTransactor = Transactor.fromDriverManager[IO](
-        hubConfiguration.database.driver,
-        hubConfiguration.database.url,
-        hubConfiguration.database.user,
-        hubConfiguration.database.pass
-      )
-      ticketsTransactor = Transactor.fromDriverManager[IO](
-        ticketsConfiguration.database.driver,
-        ticketsConfiguration.database.url,
-        ticketsConfiguration.database.user,
-        ticketsConfiguration.database.pass
-      )
-      ticketServiceApi     = new DoobieTicketServiceApi[IO](ticketsTransactor)
-      ticketLabelsRepo     = new DoobieLabelRepository[IO](ticketsTransactor)
-      ticketMilestonesRepo = new DoobieMilestoneRepository[IO](ticketsTransactor)
-      ticketProjectsRepo   = new DoobieProjectRepository[IO](ticketsTransactor)
-      ticketsRepo          = new DoobieTicketRepository[IO](ticketsTransactor)
-      ticketRoutes = new TicketRoutes[IO](
-        ticketsConfiguration,
-        ticketLabelsRepo,
-        ticketMilestonesRepo,
-        ticketProjectsRepo,
-        ticketsRepo
-      )
-      ticketLabelRoutes     = new LabelRoutes[IO](ticketsConfiguration, ticketLabelsRepo, ticketProjectsRepo)
-      ticketMilestoneRoutes = new MilestoneRoutes[IO](ticketsConfiguration, ticketMilestonesRepo, ticketProjectsRepo)
-      cryptoClock           = java.time.Clock.systemUTC
-      csrfKey <- loadOrCreateCsrfKey(hubConfiguration.service.csrfKeyFile)
-      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
-       * the CSRF secret key will change then and thus all requests are invalid.
-       */
-      csrfMiddleware = csrfBuilder
-        .withClock(cryptoClock)
-        .withCookieDomain(Option(hubConfiguration.service.external.host.toString))
-        .withCookieName(Constants.csrfCookieName.toString)
-        .withCookiePath(Option("/"))
-        .withCSRFCheck(CSRF.checkCSRFinHeaderAndForm[IO, IO](Constants.csrfCookieName.toString, FunctionK.id))
-        .withOnFailure(
-          Response[IO](
-            headers = Headers(List(headers.Location(hubConfiguration.service.external.createFullUri(uri"/")))),
-            status = Status.SeeOther
-          ).removeCookie(Constants.csrfCookieName.toString)
-        )
-        .build
-      signAndValidate    = SignAndValidate(hubConfiguration.service.authentication.cookieSecret)
-      assetsRoutes       = resourceServiceBuilder[IO](Constants.assetsPath.path.toAbsolute.toString).toRoutes
-      authenticationRepo = new DoobieAuthenticationRepository[IO](hubTransactor)
-      authenticationWithFallThrough = AuthMiddleware.withFallThrough(
-        authenticateUserWithFallThrough(
-          authenticationRepo,
-          signAndValidate,
-          hubConfiguration.service.authentication.timeouts
+    } yield (hubMigrations, ticketMigrations)
+  }
+
+  private def generateHelp(): String =
+    s"""
+       |user delete\t[--dry-run] <name>\t- Delete a locked(!) user account from the database.
+       |user find\t<email> || <name>\t- Find a user account with the given email or name.
+       |user list\t\t\t\t- List all unlocked(!) user accounts in the database.
+       |user lock\t[--dry-run] <name>\t- Lock the user account.
+       |user unlock\t[--dry-run] <name>\t- Unlock the user account.
+       |""".stripMargin
+
+  def run(args: List[String]): IO[ExitCode] = {
+    val _ = Locale.setDefault(Locale.ENGLISH) // TODO: Make this configurable.
+    val hubConfiguration = ConfigSource
+      .fromConfig(ConfigFactory.load(getClass.getClassLoader))
+      .at(SmedereeHubConfig.location)
+      .loadOrThrow[SmedereeHubConfig]
+    args match {
+      case List("help") | List("--help") | List("-h") =>
+        // TODO: Add help information!
+        for {
+          _ <- IO(println("Welcome to the Smederee Hub Server."))
+          _ <- IO(println("==================================="))
+          _ <- IO(println("To simply start the server just run this command without arguments."))
+          _ <- IO(println(generateHelp()))
+        } yield ExitCode.Success
+      case commandString :: parameters =>
+        val hubTransactor = Transactor.fromDriverManager[IO](
+          hubConfiguration.database.driver,
+          hubConfiguration.database.url,
+          hubConfiguration.database.user,
+          hubConfiguration.database.pass
         )
-      )
-      darcsWrapper          = new DarcsCommands[IO](hubConfiguration.service.darcs.executable)
-      emailMiddleware       = new SimpleJavaMailMiddleware(hubConfiguration.service.email)
-      accountManagementRepo = new DoobieAccountManagementRepository[IO](hubTransactor)
-      accountManagementRoutes = new AccountManagementRoutes[IO](
-        accountManagementRepo,
-        hubConfiguration.service,
-        emailMiddleware,
-        signAndValidate,
-        ticketServiceApi
-      )
-      authenticationRoutes = new AuthenticationRoutes[IO](
-        cryptoClock,
-        hubConfiguration.service.authentication,
-        hubConfiguration.service.external,
-        authenticationRepo,
-        signAndValidate
-      )
-      signUpRepo      = new DoobieSignupRepository[IO](hubTransactor)
-      signUpRoutes    = new SignupRoutes[IO](hubConfiguration.service, signUpRepo)
-      landingPages    = new LandingPageRoutes[IO](hubConfiguration.service)
-      vcsMetadataRepo = new DoobieVcsMetadataRepository[IO](hubTransactor)
-      vcsRepoRoutes = new VcsRepositoryRoutes[IO](
-        hubConfiguration.service,
-        darcsWrapper,
-        vcsMetadataRepo,
-        ticketProjectsRepo
-      )
-      protectedRoutesWithFallThrough = authenticationWithFallThrough(
-        authenticationRoutes.protectedRoutes <+>
-          accountManagementRoutes.protectedRoutes <+>
-          signUpRoutes.protectedRoutes <+>
-          ticketLabelRoutes.protectedRoutes <+>
-          ticketMilestoneRoutes.protectedRoutes <+>
-          ticketRoutes.protectedRoutes <+>
-          vcsRepoRoutes.protectedRoutes <+>
-          landingPages.protectedRoutes
-      )
-      hubWebService = Router(
-        Constants.assetsPath.path.toAbsolute.toString -> assetsRoutes,
-        "/" -> (protectedRoutesWithFallThrough <+>
-          authenticationRoutes.routes <+>
-          accountManagementRoutes.routes <+>
-          signUpRoutes.routes <+>
-          ticketLabelRoutes.routes <+>
-          ticketMilestoneRoutes.routes <+>
-          ticketRoutes.routes <+>
-          vcsRepoRoutes.routes <+>
-          landingPages.routes)
-      ).orNotFound
-      // Create our ssh server fiber (or a dummy one if disabled).
-      sshServerProvider = hubConfiguration.service.ssh.enabled match {
-        case false => None
-        case true =>
-          Option(
-            new SshServerProvider(
-              hubConfiguration.service.darcs,
-              hubConfiguration.database,
-              hubConfiguration.service.ssh
+        val authenticationRepo = new DoobieAuthenticationRepository[IO](hubTransactor)
+        HubCommand.valueOf(commandString.toLowerCase(Locale.ROOT)) match {
+          case HubCommand.user =>
+            parameters match {
+              case subcommandString :: arguments =>
+                HubSubcommand.valueOf(subcommandString.toLowerCase(Locale.ROOT)) match {
+                  case HubSubcommand.delete =>
+                    val accountManagementRepo = new DoobieAccountManagementRepository[IO](hubTransactor)
+                    val dryRun                = arguments.headOption.exists(_ === "--dry-run")
+                    val name =
+                      if (dryRun)
+                        arguments.drop(1).headOption.flatMap(Username.from)
+                      else
+                        arguments.headOption.flatMap(Username.from)
+                    for {
+                      user <- name.traverse(name => authenticationRepo.findLockedAccount(name)(None))
+                      _ <- user.flatten.fold(IO(println("No such locked user account found!")))(account =>
+                        IO(println(s"Going to delete user ${account.name} (${account.email})!"))
+                      )
+                      deleted <-
+                        if (dryRun)
+                          IO.pure(0.some)
+                        else
+                          user.flatten.traverse(account => accountManagementRepo.deleteAccount(account.uid))
+                      result = deleted.getOrElse(0) match {
+                        case 0 => ExitCode.Error // If not database rows were updated we assume an error.
+                        case _ => ExitCode.Success
+                      }
+                    } yield result
+                  case HubSubcommand.find =>
+                    val email = arguments.headOption.flatMap(EmailAddress.from)
+                    val name  = arguments.headOption.flatMap(Username.from)
+                    for {
+                      userByEmail <- email.traverse(authenticationRepo.findAccountByEmail)
+                      userByName  <- name.traverse(authenticationRepo.findAccountByName)
+                      users = List(userByEmail.flatten, userByName.flatten).collect { case Some(user) =>
+                        user
+                      }
+                      _ <- IO(
+                        users.map(user =>
+                          println(s"${user.name},${user.email},${user.validatedEmail},${user.language}")
+                        )
+                      )
+                    } yield ExitCode.Success
+                  case HubSubcommand.list =>
+                    val users = authenticationRepo.allAccounts()
+                    users
+                      .foreach(user =>
+                        IO(println(s"${user.name},${user.email},${user.validatedEmail},${user.language}"))
+                      )
+                      .compile
+                      .drain
+                      .as(ExitCode.Success)
+                  case HubSubcommand.lock =>
+                    val dryRun = arguments.headOption.exists(_ === "--dry-run")
+                    val name =
+                      if (dryRun)
+                        arguments.drop(1).headOption.flatMap(Username.from)
+                      else
+                        arguments.headOption.flatMap(Username.from)
+                    for {
+                      user <- name.traverse(authenticationRepo.findAccountByName)
+                      _ <- user.flatten.fold(IO(println("No such unlocked user account found!")))(account =>
+                        IO(println(s"Going to lock user ${account.name} (${account.email})!"))
+                      )
+                      locked <-
+                        if (dryRun)
+                          IO.pure(0.some)
+                        else
+                          user.flatten.traverse(account =>
+                            authenticationRepo.lockAccount(account.uid)(UnlockToken.generate.some)
+                          )
+                      result = locked.getOrElse(0) match {
+                        case 0 => ExitCode.Error // If not database rows were updated we assume an error.
+                        case _ => ExitCode.Success
+                      }
+                    } yield result
+                  case HubSubcommand.unlock =>
+                    val dryRun = arguments.headOption.exists(_ === "--dry-run")
+                    val name =
+                      if (dryRun)
+                        arguments.drop(1).headOption.flatMap(Username.from)
+                      else
+                        arguments.headOption.flatMap(Username.from)
+                    for {
+                      user <- name.traverse(name => authenticationRepo.findLockedAccount(name)(None))
+                      _ <- user.flatten.fold(IO(println("No such locked user account found!")))(account =>
+                        IO(println(s"Going to unlock user ${account.name} (${account.email})!"))
+                      )
+                      unlocked <-
+                        if (dryRun)
+                          IO.pure(0.some)
+                        else
+                          user.flatten.traverse(account => authenticationRepo.unlockAccount(account.uid))
+                      result = unlocked.getOrElse(0) match {
+                        case 0 => ExitCode.Error // If not database rows were updated we assume an error.
+                        case _ => ExitCode.Success
+                      }
+                    } yield result
+                }
+              case _ => IO.raiseError(new IllegalArgumentException("Missing subcommand/options!"))
+            }
+        }
+      case _ =>
+        for {
+          ticketsConfiguration <- IO(
+            ConfigSource
+              .fromConfig(ConfigFactory.load(getClass.getClassLoader))
+              .at(SmedereeTicketsConfiguration.location)
+              .loadOrThrow[SmedereeTicketsConfiguration]
+          )
+          _ <- migrateDatabases(hubConfiguration, ticketsConfiguration)
+          _ <- IO {
+            val defaultSecret = "CHANGEME".getBytes(StandardCharsets.UTF_8)
+            if (java.util.Arrays.equals(defaultSecret, hubConfiguration.service.authentication.cookieSecret.toArray))
+              log.warn("SERVICE IS USING DEFAULT COOKIE SECRET! PLEASE CONFIGURE A SECURE ONE!")
+          }
+          repoCheck <- IO {
+            val repositoriesDirectory = hubConfiguration.service.darcs.repositoriesDirectory.toPath
+            if (Files.exists(repositoriesDirectory)) {
+              if (Files.isDirectory(repositoriesDirectory)) {
+                Right(s"Using repositories directory at: $repositoriesDirectory")
+              } else {
+                Left(s"Path to repositories directory exists but is not a directory: $repositoriesDirectory")
+              }
+            } else {
+              log.warn(s"Repositories directory does not exist, trying to create it: $repositoriesDirectory")
+              Files.createDirectories(
+                repositoriesDirectory,
+                PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwxr-x---"))
+              )
+              Right(s"Using repositories directory at: $repositoriesDirectory")
+            }
+          }
+          _ <- repoCheck match {
+            case Left(error)    => IO.raiseError(new RuntimeException(error))
+            case Right(message) => IO(log.info(message))
+          }
+          hubTransactor = Transactor.fromDriverManager[IO](
+            hubConfiguration.database.driver,
+            hubConfiguration.database.url,
+            hubConfiguration.database.user,
+            hubConfiguration.database.pass
+          )
+          ticketsTransactor = Transactor.fromDriverManager[IO](
+            ticketsConfiguration.database.driver,
+            ticketsConfiguration.database.url,
+            ticketsConfiguration.database.user,
+            ticketsConfiguration.database.pass
+          )
+          ticketServiceApi     = new DoobieTicketServiceApi[IO](ticketsTransactor)
+          ticketLabelsRepo     = new DoobieLabelRepository[IO](ticketsTransactor)
+          ticketMilestonesRepo = new DoobieMilestoneRepository[IO](ticketsTransactor)
+          ticketProjectsRepo   = new DoobieProjectRepository[IO](ticketsTransactor)
+          ticketsRepo          = new DoobieTicketRepository[IO](ticketsTransactor)
+          ticketRoutes = new TicketRoutes[IO](
+            ticketsConfiguration,
+            ticketLabelsRepo,
+            ticketMilestonesRepo,
+            ticketProjectsRepo,
+            ticketsRepo
+          )
+          ticketLabelRoutes = new LabelRoutes[IO](ticketsConfiguration, ticketLabelsRepo, ticketProjectsRepo)
+          ticketMilestoneRoutes = new MilestoneRoutes[IO](
+            ticketsConfiguration,
+            ticketMilestonesRepo,
+            ticketProjectsRepo
+          )
+          cryptoClock = java.time.Clock.systemUTC
+          csrfKey <- loadOrCreateCsrfKey(hubConfiguration.service.csrfKeyFile)
+          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
+           * the CSRF secret key will change then and thus all requests are invalid.
+           */
+          csrfMiddleware = csrfBuilder
+            .withClock(cryptoClock)
+            .withCookieDomain(Option(hubConfiguration.service.external.host.toString))
+            .withCookieName(Constants.csrfCookieName.toString)
+            .withCookiePath(Option("/"))
+            .withCSRFCheck(CSRF.checkCSRFinHeaderAndForm[IO, IO](Constants.csrfCookieName.toString, FunctionK.id))
+            .withOnFailure(
+              Response[IO](
+                headers = Headers(List(headers.Location(hubConfiguration.service.external.createFullUri(uri"/")))),
+                status = Status.SeeOther
+              ).removeCookie(Constants.csrfCookieName.toString)
+            )
+            .build
+          signAndValidate    = SignAndValidate(hubConfiguration.service.authentication.cookieSecret)
+          assetsRoutes       = resourceServiceBuilder[IO](Constants.assetsPath.path.toAbsolute.toString).toRoutes
+          authenticationRepo = new DoobieAuthenticationRepository[IO](hubTransactor)
+          authenticationWithFallThrough = AuthMiddleware.withFallThrough(
+            authenticateUserWithFallThrough(
+              authenticationRepo,
+              signAndValidate,
+              hubConfiguration.service.authentication.timeouts
             )
           )
-      }
-      sshServer = sshServerProvider.fold(IO.unit.as(ExitCode.Success))(
-        _.run().use(server =>
-          IO(log.info(s"SSH-Server started at ${server.getHost()}:${server.getPort()}.")) >> IO.never.as(
-            ExitCode.Success
+          darcsWrapper          = new DarcsCommands[IO](hubConfiguration.service.darcs.executable)
+          emailMiddleware       = new SimpleJavaMailMiddleware(hubConfiguration.service.email)
+          accountManagementRepo = new DoobieAccountManagementRepository[IO](hubTransactor)
+          accountManagementRoutes = new AccountManagementRoutes[IO](
+            accountManagementRepo,
+            hubConfiguration.service,
+            emailMiddleware,
+            signAndValidate,
+            ticketServiceApi
           )
-        )
-      )
-      // Create our webserver fiber.
-      resource = EmberServerBuilder
-        .default[IO]
-        .withHost(hubConfiguration.service.host)
-        .withPort(hubConfiguration.service.port)
-        .withHttpApp(csrfMiddleware.validate()(hubWebService))
-        .build
-      webServer = resource.use(server =>
-        IO(log.info("Server started at {}", server.address)) >> IO.never.as(ExitCode.Success)
-      )
-      executeFibers <- (webServer, sshServer).parTupled // We run both fibers.
-      (exitCode, _) = executeFibers
-    } yield exitCode
+          authenticationRoutes = new AuthenticationRoutes[IO](
+            cryptoClock,
+            hubConfiguration.service.authentication,
+            hubConfiguration.service.external,
+            authenticationRepo,
+            signAndValidate
+          )
+          signUpRepo      = new DoobieSignupRepository[IO](hubTransactor)
+          signUpRoutes    = new SignupRoutes[IO](hubConfiguration.service, signUpRepo)
+          landingPages    = new LandingPageRoutes[IO](hubConfiguration.service)
+          vcsMetadataRepo = new DoobieVcsMetadataRepository[IO](hubTransactor)
+          vcsRepoRoutes = new VcsRepositoryRoutes[IO](
+            hubConfiguration.service,
+            darcsWrapper,
+            vcsMetadataRepo,
+            ticketProjectsRepo
+          )
+          protectedRoutesWithFallThrough = authenticationWithFallThrough(
+            authenticationRoutes.protectedRoutes <+>
+              accountManagementRoutes.protectedRoutes <+>
+              signUpRoutes.protectedRoutes <+>
+              ticketLabelRoutes.protectedRoutes <+>
+              ticketMilestoneRoutes.protectedRoutes <+>
+              ticketRoutes.protectedRoutes <+>
+              vcsRepoRoutes.protectedRoutes <+>
+              landingPages.protectedRoutes
+          )
+          hubWebService = Router(
+            Constants.assetsPath.path.toAbsolute.toString -> assetsRoutes,
+            "/" -> (protectedRoutesWithFallThrough <+>
+              authenticationRoutes.routes <+>
+              accountManagementRoutes.routes <+>
+              signUpRoutes.routes <+>
+              ticketLabelRoutes.routes <+>
+              ticketMilestoneRoutes.routes <+>
+              ticketRoutes.routes <+>
+              vcsRepoRoutes.routes <+>
+              landingPages.routes)
+          ).orNotFound
+          // Create our ssh server fiber (or a dummy one if disabled).
+          sshServerProvider = hubConfiguration.service.ssh.enabled match {
+            case false => None
+            case true =>
+              Option(
+                new SshServerProvider(
+                  hubConfiguration.service.darcs,
+                  hubConfiguration.database,
+                  hubConfiguration.service.ssh
+                )
+              )
+          }
+          sshServer = sshServerProvider.fold(IO.unit.as(ExitCode.Success))(
+            _.run().use(server =>
+              IO(log.info(s"SSH-Server started at ${server.getHost()}:${server.getPort()}.")) >> IO.never.as(
+                ExitCode.Success
+              )
+            )
+          )
+          // Create our webserver fiber.
+          resource = EmberServerBuilder
+            .default[IO]
+            .withHost(hubConfiguration.service.host)
+            .withPort(hubConfiguration.service.port)
+            .withHttpApp(csrfMiddleware.validate()(hubWebService))
+            .build
+          webServer = resource.use(server =>
+            IO(log.info("Server started at {}", server.address)) >> IO.never.as(ExitCode.Success)
+          )
+          executeFibers <- (webServer, sshServer).parTupled // We run both fibers.
+          (exitCode, _) = executeFibers
+        } yield exitCode
+    }
   }
 }