~jan0sch/smederee
Showing details for patch 252c14a83f46a13760979e5af3a79fb46772670a.
diff -rN -u old-smederee/build.sbt new-smederee/build.sbt --- old-smederee/build.sbt 2025-01-31 10:46:28.154967279 +0000 +++ new-smederee/build.sbt 2025-01-31 10:46:28.170967306 +0000 @@ -14,7 +14,7 @@ inThisBuild( Seq( - scalaVersion := "3.2.2", + scalaVersion := "3.3.0-RC3", organization := "de.smederee", organizationName := "Contributors as noted in the AUTHORS.md file", scalacOptions ++= Seq( @@ -25,7 +25,14 @@ "-language:higherKinds", "-language:implicitConversions", "-unchecked", - "-Xfatal-warnings", + "-Wunused:imports", // Warn on unused imports including given and wildcard imports. + "-Wunused:linted", + "-Wunused:locals", + //"-Wunused:params", + "-Wunused:privates", + //"-Wunused:unsafe-warn-patvars", + //"-Wvalue-discard", // TODO: Evaluate and apply if possible. + //"-Xfatal-warnings", // FIXME: Make this work despite of Twirl! "-Ykind-projector", ), resolvers += "jitpack" at "https://jitpack.io", // for JANSI fork @@ -50,7 +57,7 @@ publish := {}, publishLocal := {} ) - .aggregate(darcs, email, htmlUtils, hub, i18n, security, twirl) + .aggregate(darcs, email, htmlUtils, hub, i18n, security, tickets, twirl) lazy val darcs = project @@ -140,7 +147,7 @@ lazy val hub = project .in(file("modules/hub")) - .dependsOn(darcs, email, htmlUtils, i18n, security, twirl) + .dependsOn(darcs, email, htmlUtils, i18n, security, tickets, twirl) .enablePlugins( AutomateHeaderPlugin, DebianPlugin, @@ -208,15 +215,8 @@ TwirlKeys.templateImports ++= Seq( "cats.syntax.all._", "de.smederee.html._", - "de.smederee.hub._", - "de.smederee.hub.config._", - "de.smederee.hub.forms.types._", - "de.smederee.hub.forms._", - "de.smederee.hub.views.html.csrfToken", - "de.smederee.hub.views.html.forms.renderFormErrors", - "de.smederee.hub.views.html.icon", - "de.smederee.hub.views.html.main", - "de.smederee.i18n.Messages", + "de.smederee.i18n._", + "de.smederee.security.CsrfToken", "org.http4s.Uri" ) ) @@ -314,6 +314,57 @@ ), ) +lazy val tickets = + project + .in(file("modules/tickets")) + .dependsOn(email, htmlUtils, security) + .enablePlugins(AutomateHeaderPlugin) + .configs(IntegrationTest) + .settings(commonSettings) + .settings( + name := "tickets", + version := "0.5.0-SNAPSHOT", + Defaults.itSettings, + headerSettings(IntegrationTest), + inConfig(IntegrationTest)(scalafmtSettings), + IntegrationTest / console / scalacOptions --= Seq("-Xfatal-warnings"), + IntegrationTest / parallelExecution := false, + libraryDependencies ++= Seq( + library.catsCore, + library.circeCore, + library.circeGeneric, + library.circeParser, + library.commonMark, + library.commonMarkExtHeadingAnchor, + library.commonMarkExtTables, + library.commonMarkExtTaskListItems, + library.doobieCore, + library.doobieHikari, + library.doobiePostgres, + library.flywayCore, + library.http4sCirce, + library.http4sDsl, + library.http4sEmberClient, + library.http4sEmberServer, + //library.http4sTwirl, + library.jclOverSlf4j, // Bridge Java Commons Logging to SLF4J. + library.logback, + library.postgresql, + library.pureConfig, + library.springSecurityCrypto, + library.munit % IntegrationTest, + library.munitCatsEffect % IntegrationTest, + library.munitDiscipline % IntegrationTest, + library.munitScalaCheck % IntegrationTest, + library.scalaCheck % IntegrationTest, + library.munit % Test, + library.munitCatsEffect % Test, + library.munitDiscipline % Test, + library.munitScalaCheck % Test, + library.scalaCheck % Test + ) + ) + // FIXME This is a workaround until http4s-twirl gets published properly for Scala 3! lazy val twirl = project @@ -342,10 +393,10 @@ val bouncyCastle = "1.72" val cats = "2.9.0" val catsEffect = "3.4.8" - val circe = "0.14.4" + val circe = "0.14.5" val commonMark = "0.21.0" val doobie = "1.0.0-RC2" - val flyway = "9.15.1" + val flyway = "9.15.2" val fs2 = "3.5.0" val http4s = "1.0.0-M39" val ip4s = "3.2.0" @@ -355,7 +406,7 @@ val munit = "0.7.29" val munitCatsEffect = "1.0.7" val munitDiscipline = "1.0.9" - val osLib = "0.9.0" + val osLib = "0.9.1" val postgresql = "42.6.0" val pureConfig = "0.17.2" val scalaCheck = "1.17.0" diff -rN -u old-smederee/.ignore new-smederee/.ignore --- old-smederee/.ignore 2025-01-31 10:46:28.154967279 +0000 +++ new-smederee/.ignore 2025-01-31 10:46:28.170967306 +0000 @@ -24,4 +24,4 @@ Session.vim tags # Project speficic files for local development -modules/hub/src/main/resources/application.conf +modules/.*/src/main/resources/application.conf diff -rN -u old-smederee/.jvmopts new-smederee/.jvmopts --- old-smederee/.jvmopts 2025-01-31 10:46:28.154967279 +0000 +++ new-smederee/.jvmopts 2025-01-31 10:46:28.170967306 +0000 @@ -1,5 +1,8 @@ --server --Xms2g --Xmx2g --Xss4m +-Dfile.encoding=UTF8 +-Xms1G +-Xmx3G +-Xss4M +-XX:MaxMetaspaceSize=512M +-XX:ReservedCodeCacheSize=256M +-XX:+TieredCompilation -XX:+UseG1GC diff -rN -u old-smederee/modules/darcs/src/main/scala/de/smederee/darcs/DarcsCommands.scala new-smederee/modules/darcs/src/main/scala/de/smederee/darcs/DarcsCommands.scala --- old-smederee/modules/darcs/src/main/scala/de/smederee/darcs/DarcsCommands.scala 2025-01-31 10:46:28.154967279 +0000 +++ new-smederee/modules/darcs/src/main/scala/de/smederee/darcs/DarcsCommands.scala 2025-01-31 10:46:28.170967306 +0000 @@ -25,7 +25,6 @@ import cats.syntax.all._ import org.slf4j.LoggerFactory -import scala.sys.process._ import scala.util.matching.Regex opaque type DarcsHash = String diff -rN -u old-smederee/modules/email/src/main/scala/de/smederee/email/EmailMiddleware.scala new-smederee/modules/email/src/main/scala/de/smederee/email/EmailMiddleware.scala --- old-smederee/modules/email/src/main/scala/de/smederee/email/EmailMiddleware.scala 2025-01-31 10:46:28.154967279 +0000 +++ new-smederee/modules/email/src/main/scala/de/smederee/email/EmailMiddleware.scala 2025-01-31 10:46:28.170967306 +0000 @@ -91,6 +91,76 @@ } +/** An email address must fulfil several format requirements which in detail should be looked up in the implementation. + */ +opaque type EmailAddress = String +object EmailAddress { + given Eq[EmailAddress] = Eq.fromUniversalEquals + + val validateString: Regex = + """^([a-zA-Z0-9.!#$%&’'*+/=?^_`{|}~-]+)@([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*)$""".r + + /** Create an instance of EmailAddress from the given String type. + * + * @param source + * An instance of type String which will be returned as a EmailAddress. + * @return + * The appropriate instance of EmailAddress. + */ + def apply(source: String): EmailAddress = source + + /** Try to create an instance of EmailAddress from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a EmailAddress. + * @return + * An option to the successfully converted EmailAddress. + */ + def from(source: String): Option[EmailAddress] = { + // Must obviously not be null or empty. + val notEmpty = Option(source) + // Must have at least one '@' sign. + val hasAtChar = Option(source).filter(_.exists(_ === '@')) + // Must have at least one '.' character. + val hasDotChar = Option(source).filter(_.exists(_ === '.')) + // The maximum allowed length is 128 characters. + val belowMaxLength = Option(source).filter(_.length <= 128) + // The last part of the email address (usually the top level domain) must at least be 2 characters long. + val lastPartValid = Option(source) match { + case None => None + case Some(string) => + val parts = string.split('.') + if (parts.lastOption.map(_.length >= 2).getOrElse(false)) + Option(string) + else + None + } + // Check against a regular expression. TODO Maybe we can skip the other tests alltogether? + val passesRegularExpression = Option(source).filter(validateString.matches) + // Validate pre-conditions + (notEmpty, hasAtChar, hasDotChar, belowMaxLength, lastPartValid, passesRegularExpression).mapN { + case (email, _, _, _, _, _) => email + } + } + + extension (email: EmailAddress) { + + /** Convert the email into a [[de.smederee.email.FromAddress]] to be useable within the email middleware. + * + * @return + * The email address to be used in a `From` header. + */ + def toFromAddress: FromAddress = FromAddress(email.toString) + + /** Convert the email into a [[de.smederee.email.ToAddress]] to be useable within the email middleware. + * + * @return + * The email address to be used in a `To` header. + */ + def toToAddress: ToAddress = ToAddress(email.toString) + } +} + opaque type FromAddress = String object FromAddress { given Eq[FromAddress] = Eq.fromUniversalEquals diff -rN -u old-smederee/modules/email/src/test/scala/de/smederee/email/EmailMiddlewareTest.scala new-smederee/modules/email/src/test/scala/de/smederee/email/EmailMiddlewareTest.scala --- old-smederee/modules/email/src/test/scala/de/smederee/email/EmailMiddlewareTest.scala 2025-01-31 10:46:28.154967279 +0000 +++ new-smederee/modules/email/src/test/scala/de/smederee/email/EmailMiddlewareTest.scala 2025-01-31 10:46:28.170967306 +0000 @@ -19,18 +19,12 @@ import java.nio.charset.StandardCharsets -import cats.data._ -import cats.kernel.Eq -import cats.syntax.all._ import de.smederee.email.Generators._ -import jakarta.mail.Message.RecipientType import munit._ import org.scalacheck._ import org.scalacheck.Prop._ -import scala.jdk.CollectionConverters._ - final class EmailMiddlewareTest extends ScalaCheckSuite { given Arbitrary[FromAddress] = Arbitrary(genValidFromAddress) given Arbitrary[ToAddress] = Arbitrary(genValidToAddress) diff -rN -u old-smederee/modules/email/src/test/scala/de/smederee/email/SimpleJavaMailMiddlewareHelpersTest.scala new-smederee/modules/email/src/test/scala/de/smederee/email/SimpleJavaMailMiddlewareHelpersTest.scala --- old-smederee/modules/email/src/test/scala/de/smederee/email/SimpleJavaMailMiddlewareHelpersTest.scala 2025-01-31 10:46:28.154967279 +0000 +++ new-smederee/modules/email/src/test/scala/de/smederee/email/SimpleJavaMailMiddlewareHelpersTest.scala 2025-01-31 10:46:28.170967306 +0000 @@ -17,7 +17,6 @@ package de.smederee.email -import cats.data._ import cats.kernel.Eq import cats.syntax.all._ import de.smederee.email.Generators._ diff -rN -u old-smederee/modules/html-utils/src/test/scala/de/smederee/html/LinkToolsTest.scala new-smederee/modules/html-utils/src/test/scala/de/smederee/html/LinkToolsTest.scala --- old-smederee/modules/html-utils/src/test/scala/de/smederee/html/LinkToolsTest.scala 2025-01-31 10:46:28.154967279 +0000 +++ new-smederee/modules/html-utils/src/test/scala/de/smederee/html/LinkToolsTest.scala 2025-01-31 10:46:28.170967306 +0000 @@ -18,12 +18,11 @@ package de.smederee.html import com.comcast.ip4s._ +import de.smederee.html.LinkTools.createFullUri import org.http4s.Uri import org.http4s.implicits._ import munit._ -import org.scalacheck._ -import de.smederee.html.LinkTools.createFullUri final class LinkToolsTest extends ScalaCheckSuite { diff -rN -u old-smederee/modules/hub/src/it/resources/application.conf new-smederee/modules/hub/src/it/resources/application.conf --- old-smederee/modules/hub/src/it/resources/application.conf 2025-01-31 10:46:28.154967279 +0000 +++ new-smederee/modules/hub/src/it/resources/application.conf 2025-01-31 10:46:28.170967306 +0000 @@ -1,8 +1,12 @@ -database { - url = "jdbc:postgresql://localhost:5432/smederee_hub_it" - url = ${?SMEDEREE_HUB_TEST_DB_URL} - user = "smederee_hub" - user = ${?SMEDEREE_HUB_TEST_DB_USER} - pass = "secret" - pass = ${?SMEDEREE_HUB_TEST_DB_PASS} +hub { + database { + host = localhost + host = ${?SMEDEREE_DB_HOST} + url = "jdbc:postgresql://"${hub.database.host}":5432/smederee_hub_it" + url = ${?SMEDEREE_HUB_TEST_DB_URL} + user = "smederee_hub" + user = ${?SMEDEREE_HUB_TEST_DB_USER} + pass = "secret" + pass = ${?SMEDEREE_HUB_TEST_DB_PASS} + } } diff -rN -u old-smederee/modules/hub/src/it/resources/logback-test.xml new-smederee/modules/hub/src/it/resources/logback-test.xml --- old-smederee/modules/hub/src/it/resources/logback-test.xml 2025-01-31 10:46:28.154967279 +0000 +++ new-smederee/modules/hub/src/it/resources/logback-test.xml 2025-01-31 10:46:28.170967306 +0000 @@ -19,6 +19,10 @@ <appender-ref ref="async-console"/> </logger> + <logger name="org.flywaydb.core" level="ERROR" additivity="false"> + <appender-ref ref="async-console"/> + </logger> + <root> <appender-ref ref="async-console"/> </root> diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala --- old-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala 2025-01-31 10:46:28.154967279 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala 2025-01-31 10:46:28.170967306 +0000 @@ -26,14 +26,14 @@ import cats.syntax.all._ import com.comcast.ip4s._ import com.typesafe.config.ConfigFactory +import de.smederee.email.EmailAddress import de.smederee.hub.config._ +import de.smederee.security._ import org.flywaydb.core.Flyway import pureconfig._ import munit._ -import scala.concurrent.duration._ - /** Base class for our integration test suites. * * It loads and provides the configuration as a resource suit fixture (loaded once for all tests within a suite) and @@ -43,7 +43,10 @@ abstract class BaseSpec extends CatsEffectSuite { protected final val configuration: SmedereeHubConfig = - ConfigSource.fromConfig(ConfigFactory.load(getClass.getClassLoader)).loadOrThrow[SmedereeHubConfig] + ConfigSource + .fromConfig(ConfigFactory.load(getClass.getClassLoader)) + .at(SmedereeHubConfig.location) + .loadOrThrow[SmedereeHubConfig] protected final val flyway: Flyway = DatabaseMigrator @@ -252,7 +255,7 @@ Account( uid = uid, name = Username(result.getString("name")), - email = Email(result.getString("email")), + email = EmailAddress(result.getString("email")), validatedEmail = result.getBoolean("validated_email") ) ) diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala --- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala 2025-01-31 10:46:28.154967279 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala 2025-01-31 10:46:28.170967306 +0000 @@ -24,6 +24,7 @@ import cats.syntax.all._ import de.smederee.hub.Generators._ import de.smederee.hub.config.SmedereeHubConfig +import de.smederee.security._ import de.smederee.ssh._ import doobie._ import org.flywaydb.core.Flyway diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala --- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala 2025-01-31 10:46:28.154967279 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala 2025-01-31 10:46:28.170967306 +0000 @@ -23,6 +23,7 @@ import cats.syntax.all._ import de.smederee.hub.Generators._ import de.smederee.hub.config.SmedereeHubConfig +import de.smederee.security._ import doobie._ import org.flywaydb.core.Flyway diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieSignupRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieSignupRepositoryTest.scala --- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieSignupRepositoryTest.scala 2025-01-31 10:46:28.154967279 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieSignupRepositoryTest.scala 2025-01-31 10:46:28.170967306 +0000 @@ -22,6 +22,7 @@ import cats.effect._ import cats.syntax.all._ import de.smederee.hub.Generators._ +import de.smederee.security._ import doobie._ import org.flywaydb.core.Flyway diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala --- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala 2025-01-31 10:46:28.154967279 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala 2025-01-31 10:46:28.170967306 +0000 @@ -19,9 +19,11 @@ import cats.effect._ import cats.syntax.all._ +import de.smederee.email.EmailAddress import de.smederee.hub.Generators._ import de.smederee.hub.VcsMetadataRepositoriesOrdering._ import de.smederee.hub.config.SmedereeHubConfig +import de.smederee.security._ import doobie._ import org.flywaydb.core.Flyway import org.http4s.implicits._ @@ -270,7 +272,7 @@ Account( repo.owner.uid, repo.owner.name, - Email(s"${repo.owner.name}@example.com"), + EmailAddress(s"${repo.owner.name}@example.com"), validatedEmail = true ) ) @@ -307,7 +309,7 @@ Account( repo.owner.uid, repo.owner.name, - Email(s"${repo.owner.name}@example.com"), + EmailAddress(s"${repo.owner.name}@example.com"), validatedEmail = true ) ) @@ -347,7 +349,7 @@ Account( repo.owner.uid, repo.owner.name, - Email(s"${repo.owner.name}@example.com"), + EmailAddress(s"${repo.owner.name}@example.com"), validatedEmail = true ) ) diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/Generators.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/Generators.scala --- old-smederee/modules/hub/src/it/scala/de/smederee/hub/Generators.scala 2025-01-31 10:46:28.154967279 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/Generators.scala 2025-01-31 10:46:28.170967306 +0000 @@ -22,7 +22,8 @@ import java.util.{ Locale, UUID } import cats.syntax.all._ -import de.smederee.security.{ PrivateKey, SignAndValidate } +import de.smederee.email.EmailAddress +import de.smederee.security._ import org.scalacheck._ @@ -80,12 +81,19 @@ val genUUID: Gen[UUID] = Gen.delay(UUID.randomUUID) - val genValidEmail: Gen[Email] = - for { - length <- Gen.choose(4, 64) - chars <- Gen.nonEmptyListOf(Gen.alphaNumChar) - email = chars.take(length).mkString - } yield Email(email + "@example.com") + private val validEmailAddressPrefixChars = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.!#$%&’'*+/=?^_`{|}~-".toList + private val validDomainNameChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-".toList + + val genValidEmail: Gen[EmailAddress] = for { + prefix <- Gen.nonEmptyListOf(Gen.oneOf(validEmailAddressPrefixChars)).map(_.take(64).mkString) + domain <- Gen.nonEmptyListOf(Gen.oneOf(validDomainNameChars)).map(_.take(32).mkString) + topLevelDomain <- Gen + .nonEmptyListOf(Gen.oneOf(validDomainNameChars)) + .suchThat(_.length >= 2) + .map(_.take(24).mkString) + suffix = s"$domain.$topLevelDomain" + } yield EmailAddress(s"$prefix@$suffix") val genValidUsername: Gen[Username] = for { length <- Gen.choose(2, 30) diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/hub/V1__base_tables.sql new-smederee/modules/hub/src/main/resources/db/migration/hub/V1__base_tables.sql --- old-smederee/modules/hub/src/main/resources/db/migration/hub/V1__base_tables.sql 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/resources/db/migration/hub/V1__base_tables.sql 2025-01-31 10:46:28.174967313 +0000 @@ -0,0 +1,88 @@ +CREATE SCHEMA IF NOT EXISTS "hub"; + +CREATE TABLE "hub"."accounts" +( + "uid" UUID NOT NULL, + "name" CHARACTER VARYING(32) NOT NULL, + "email" CHARACTER VARYING(128) NOT NULL, + "password" TEXT, + "failed_attempts" INTEGER DEFAULT 0, + "locked_at" TIMESTAMP WITH TIME ZONE DEFAULT NULL, + "unlock_token" TEXT DEFAULT NULL, + "reset_expiry" TIMESTAMP WITH TIME ZONE DEFAULT NULL, + "reset_token" TEXT DEFAULT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "validated_email" BOOLEAN DEFAULT FALSE, + "validation_token" TEXT DEFAULT NULL, + CONSTRAINT "accounts_pk" PRIMARY KEY ("uid"), + CONSTRAINT "accounts_unique_name" UNIQUE ("name"), + CONSTRAINT "accounts_unique_email" UNIQUE ("email") +) +WITH ( + OIDS=FALSE +); + +COMMENT ON TABLE "hub"."accounts" IS 'All user accounts for the system live within this table.'; +COMMENT ON COLUMN "hub"."accounts"."uid" IS 'A globally unique ID for the related user account.'; +COMMENT ON COLUMN "hub"."accounts"."name" IS 'A username between 2 and 32 characters which must be globally unique, contain only lowercase alphanumeric characters and start with a character.'; +COMMENT ON COLUMN "hub"."accounts"."email" IS 'A globally unique email address associated with the account.'; +COMMENT ON COLUMN "hub"."accounts"."password" IS 'The hashed password for the account.'; +COMMENT ON COLUMN "hub"."accounts"."failed_attempts" IS 'The number of failed login attempts since the last sucessful login.'; +COMMENT ON COLUMN "hub"."accounts"."locked_at" IS 'A timestamp when the account was locked. If this value is not NULL then the account should be considered locked'; +COMMENT ON COLUMN "hub"."accounts"."unlock_token" IS 'An unlock token which can be used to unlock the account.'; +COMMENT ON COLUMN "hub"."accounts"."reset_expiry" IS 'The timestamp when the reset token will expire.'; +COMMENT ON COLUMN "hub"."accounts"."reset_token" IS 'A token which can be used for a password reset.'; +COMMENT ON COLUMN "hub"."accounts"."created_at" IS 'The timestamp of when the account was created.'; +COMMENT ON COLUMN "hub"."accounts"."updated_at" IS 'A timestamp when the account was last changed.'; +COMMENT ON COLUMN "hub"."accounts"."validated_email" IS 'This flag indicates if the email address of the user has been validated via a validation email.'; +COMMENT ON COLUMN "hub"."accounts"."validation_token" IS 'A token used to validate the email address of the user.'; + +CREATE TABLE "hub"."sessions" +( + "id" VARCHAR(32) NOT NULL, + "uid" UUID NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT "sessions_pk" PRIMARY KEY ("id"), + CONSTRAINT "sessions_fk_uid" FOREIGN KEY ("uid") + REFERENCES "hub"."accounts" ("uid") ON UPDATE CASCADE ON DELETE CASCADE +) +WITH ( + OIDS=FALSE +); + +COMMENT ON TABLE "hub"."sessions" IS 'Keeps the sessions of users.'; +COMMENT ON COLUMN "hub"."sessions"."id" IS 'A globally unique session ID.'; +COMMENT ON COLUMN "hub"."sessions"."uid" IS 'The unique ID of the user account to whom the session belongs.'; +COMMENT ON COLUMN "hub"."sessions"."created_at" IS 'The timestamp of when the session was created.'; +COMMENT ON COLUMN "hub"."sessions"."updated_at" IS 'The session ID should be re-generated in regular intervals resulting in a copy of the old session entry with a new ID and the corresponding timestamp in this column.'; + +CREATE TABLE "hub"."ssh_keys" +( + "id" UUID NOT NULL, + "uid" UUID NOT NULL, + "key_type" CHARACTER VARYING(32) NOT NULL, + "key" TEXT NOT NULL, + "fingerprint" CHARACTER VARYING(256) NOT NULL, + "comment" CHARACTER VARYING(256) DEFAULT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "last_used_at" TIMESTAMP WITH TIME ZONE DEFAULT NULL, + CONSTRAINT "ssh_keys_pk" PRIMARY KEY ("id"), + CONSTRAINT "ssh_keys_unique_fp" UNIQUE ("fingerprint"), + CONSTRAINT "ssh_keys_fk_uid" FOREIGN KEY ("uid") + REFERENCES "hub"."accounts" ("uid") ON UPDATE CASCADE ON DELETE CASCADE +) +WITH ( + OIDS=FALSE +); + +COMMENT ON TABLE "hub"."ssh_keys" IS 'SSH keys uploaded by users live within this table. Updates are not intended, so keys have to be deleted and re-uploaded upon changes.'; +COMMENT ON COLUMN "hub"."ssh_keys"."id" IS 'The globally unique ID of the ssh key.'; +COMMENT ON COLUMN "hub"."ssh_keys"."uid" IS 'The unique ID of the user account to whom the ssh key belongs.'; +COMMENT ON COLUMN "hub"."ssh_keys"."key_type" IS 'The type of the key e.g. ssh-rsa or ssh-ed25519 and so on.'; +COMMENT ON COLUMN "hub"."ssh_keys"."key" IS 'A base 64 string containing the public ssh key.'; +COMMENT ON COLUMN "hub"."ssh_keys"."fingerprint" IS 'The fingerprint of the ssh key. It must be unique because a key can only be used by one account.'; +COMMENT ON COLUMN "hub"."ssh_keys"."comment" IS 'An optional comment for the ssh key limited to 256 characters.'; +COMMENT ON COLUMN "hub"."ssh_keys"."created_at" IS 'The timestamp of when the ssh key was created.'; +COMMENT ON COLUMN "hub"."ssh_keys"."last_used_at" IS 'The timestamp of when the ssh key was last used by the user.'; diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/hub/V2__repository_tables.sql new-smederee/modules/hub/src/main/resources/db/migration/hub/V2__repository_tables.sql --- old-smederee/modules/hub/src/main/resources/db/migration/hub/V2__repository_tables.sql 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/resources/db/migration/hub/V2__repository_tables.sql 2025-01-31 10:46:28.174967313 +0000 @@ -0,0 +1,30 @@ +CREATE TABLE "hub"."repositories" +( + "id" BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + "name" CHARACTER VARYING(64) NOT NULL, + "owner" UUID NOT NULL, + "is_private" BOOLEAN NOT NULL DEFAULT FALSE, + "description" CHARACTER VARYING(254), + "vcs_type" CHARACTER VARYING(16) NOT NULL, + "website" TEXT, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT "repositories_unique_owner_name" UNIQUE ("owner", "name"), + CONSTRAINT "repositories_fk_uid" FOREIGN KEY ("owner") + REFERENCES "hub"."accounts" ("uid") ON UPDATE CASCADE ON DELETE CASCADE +) +WITH ( + OIDS=FALSE +); + +COMMENT ON TABLE "hub"."repositories" IS 'All repositories i.e. their metadata are stored within this table. The combination of owner id and repository name must always be unique.'; +COMMENT ON COLUMN "hub"."repositories"."id" IS 'An auto generated primary key.'; +COMMENT ON COLUMN "hub"."repositories"."name" IS 'A repository name must start with a letter or number and must contain only alphanumeric ASCII characters as well as minus or underscore signs. It must be between 2 and 64 characters long.'; +COMMENT ON COLUMN "hub"."repositories"."owner" IS 'The unique ID of the user account that owns the repository.'; +COMMENT ON COLUMN "hub"."repositories"."is_private" IS 'A flag indicating if this repository is private i.e. only visible / accessible for accounts with appropriate permissions.'; +COMMENT ON COLUMN "hub"."repositories"."description" IS 'An optional short text description of the repository.'; +COMMENT ON COLUMN "hub"."repositories"."vcs_type" IS 'The type of the underlying DVCS that manages the repository.'; +COMMENT ON COLUMN "hub"."repositories"."website" IS 'An optional uri pointing to a website related to the repository / project.'; +COMMENT ON COLUMN "hub"."repositories"."created_at" IS 'The timestamp of when the repository was created.'; +COMMENT ON COLUMN "hub"."repositories"."updated_at" IS 'A timestamp when the repository was last changed.'; + diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/hub/V3__fork_tables.sql new-smederee/modules/hub/src/main/resources/db/migration/hub/V3__fork_tables.sql --- old-smederee/modules/hub/src/main/resources/db/migration/hub/V3__fork_tables.sql 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/resources/db/migration/hub/V3__fork_tables.sql 2025-01-31 10:46:28.174967313 +0000 @@ -0,0 +1,18 @@ +CREATE TABLE "hub"."forks" +( + "original_repo" BIGINT NOT NULL, + "forked_repo" BIGINT NOT NULL, + CONSTRAINT "forks_pk" PRIMARY KEY ("original_repo", "forked_repo"), + CONSTRAINT "forks_fk_original_repo" FOREIGN KEY ("original_repo") + REFERENCES "hub"."repositories" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "forks_fk_forked_repo" FOREIGN KEY ("forked_repo") + REFERENCES "hub"."repositories" ("id") ON UPDATE CASCADE ON DELETE CASCADE +) +WITH ( + OIDS=FALSE +); + +COMMENT ON TABLE "hub"."forks" IS 'Stores fork relationships between repositories.'; +COMMENT ON COLUMN "hub"."forks"."original_repo" IS 'The ID of the original repository from which was forked.'; +COMMENT ON COLUMN "hub"."forks"."forked_repo" IS 'The ID of the repository which is the fork.'; + diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql new-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql --- old-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql 2025-01-31 10:46:28.154967279 +0000 +++ new-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql 1970-01-01 00:00:00.000000000 +0000 @@ -1,88 +0,0 @@ -CREATE SCHEMA IF NOT EXISTS "hub"; - -CREATE TABLE "hub"."accounts" -( - "uid" UUID NOT NULL, - "name" CHARACTER VARYING(32) NOT NULL, - "email" CHARACTER VARYING(128) NOT NULL, - "password" TEXT, - "failed_attempts" INTEGER DEFAULT 0, - "locked_at" TIMESTAMP WITH TIME ZONE DEFAULT NULL, - "unlock_token" TEXT DEFAULT NULL, - "reset_expiry" TIMESTAMP WITH TIME ZONE DEFAULT NULL, - "reset_token" TEXT DEFAULT NULL, - "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, - "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, - "validated_email" BOOLEAN DEFAULT FALSE, - "validation_token" TEXT DEFAULT NULL, - CONSTRAINT "accounts_pk" PRIMARY KEY ("uid"), - CONSTRAINT "accounts_unique_name" UNIQUE ("name"), - CONSTRAINT "accounts_unique_email" UNIQUE ("email") -) -WITH ( - OIDS=FALSE -); - -COMMENT ON TABLE "hub"."accounts" IS 'All user accounts for the system live within this table.'; -COMMENT ON COLUMN "hub"."accounts"."uid" IS 'A globally unique ID for the related user account.'; -COMMENT ON COLUMN "hub"."accounts"."name" IS 'A username between 2 and 32 characters which must be globally unique, contain only lowercase alphanumeric characters and start with a character.'; -COMMENT ON COLUMN "hub"."accounts"."email" IS 'A globally unique email address associated with the account.'; -COMMENT ON COLUMN "hub"."accounts"."password" IS 'The hashed password for the account.'; -COMMENT ON COLUMN "hub"."accounts"."failed_attempts" IS 'The number of failed login attempts since the last sucessful login.'; -COMMENT ON COLUMN "hub"."accounts"."locked_at" IS 'A timestamp when the account was locked. If this value is not NULL then the account should be considered locked'; -COMMENT ON COLUMN "hub"."accounts"."unlock_token" IS 'An unlock token which can be used to unlock the account.'; -COMMENT ON COLUMN "hub"."accounts"."reset_expiry" IS 'The timestamp when the reset token will expire.'; -COMMENT ON COLUMN "hub"."accounts"."reset_token" IS 'A token which can be used for a password reset.'; -COMMENT ON COLUMN "hub"."accounts"."created_at" IS 'The timestamp of when the account was created.'; -COMMENT ON COLUMN "hub"."accounts"."updated_at" IS 'A timestamp when the account was last changed.'; -COMMENT ON COLUMN "hub"."accounts"."validated_email" IS 'This flag indicates if the email address of the user has been validated via a validation email.'; -COMMENT ON COLUMN "hub"."accounts"."validation_token" IS 'A token used to validate the email address of the user.'; - -CREATE TABLE "hub"."sessions" -( - "id" VARCHAR(32) NOT NULL, - "uid" UUID NOT NULL, - "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, - "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, - CONSTRAINT "sessions_pk" PRIMARY KEY ("id"), - CONSTRAINT "sessions_fk_uid" FOREIGN KEY ("uid") - REFERENCES "hub"."accounts" ("uid") ON UPDATE CASCADE ON DELETE CASCADE -) -WITH ( - OIDS=FALSE -); - -COMMENT ON TABLE "hub"."sessions" IS 'Keeps the sessions of users.'; -COMMENT ON COLUMN "hub"."sessions"."id" IS 'A globally unique session ID.'; -COMMENT ON COLUMN "hub"."sessions"."uid" IS 'The unique ID of the user account to whom the session belongs.'; -COMMENT ON COLUMN "hub"."sessions"."created_at" IS 'The timestamp of when the session was created.'; -COMMENT ON COLUMN "hub"."sessions"."updated_at" IS 'The session ID should be re-generated in regular intervals resulting in a copy of the old session entry with a new ID and the corresponding timestamp in this column.'; - -CREATE TABLE "hub"."ssh_keys" -( - "id" UUID NOT NULL, - "uid" UUID NOT NULL, - "key_type" CHARACTER VARYING(32) NOT NULL, - "key" TEXT NOT NULL, - "fingerprint" CHARACTER VARYING(256) NOT NULL, - "comment" CHARACTER VARYING(256) DEFAULT NULL, - "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, - "last_used_at" TIMESTAMP WITH TIME ZONE DEFAULT NULL, - CONSTRAINT "ssh_keys_pk" PRIMARY KEY ("id"), - CONSTRAINT "ssh_keys_unique_fp" UNIQUE ("fingerprint"), - CONSTRAINT "ssh_keys_fk_uid" FOREIGN KEY ("uid") - REFERENCES "hub"."accounts" ("uid") ON UPDATE CASCADE ON DELETE CASCADE -) -WITH ( - OIDS=FALSE -); - -COMMENT ON TABLE "hub"."ssh_keys" IS 'SSH keys uploaded by users live within this table. Updates are not intended, so keys have to be deleted and re-uploaded upon changes.'; -COMMENT ON COLUMN "hub"."ssh_keys"."id" IS 'The globally unique ID of the ssh key.'; -COMMENT ON COLUMN "hub"."ssh_keys"."uid" IS 'The unique ID of the user account to whom the ssh key belongs.'; -COMMENT ON COLUMN "hub"."ssh_keys"."key_type" IS 'The type of the key e.g. ssh-rsa or ssh-ed25519 and so on.'; -COMMENT ON COLUMN "hub"."ssh_keys"."key" IS 'A base 64 string containing the public ssh key.'; -COMMENT ON COLUMN "hub"."ssh_keys"."fingerprint" IS 'The fingerprint of the ssh key. It must be unique because a key can only be used by one account.'; -COMMENT ON COLUMN "hub"."ssh_keys"."comment" IS 'An optional comment for the ssh key limited to 256 characters.'; -COMMENT ON COLUMN "hub"."ssh_keys"."created_at" IS 'The timestamp of when the ssh key was created.'; -COMMENT ON COLUMN "hub"."ssh_keys"."last_used_at" IS 'The timestamp of when the ssh key was last used by the user.'; diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/V2__repository_tables.sql new-smederee/modules/hub/src/main/resources/db/migration/V2__repository_tables.sql --- old-smederee/modules/hub/src/main/resources/db/migration/V2__repository_tables.sql 2025-01-31 10:46:28.154967279 +0000 +++ new-smederee/modules/hub/src/main/resources/db/migration/V2__repository_tables.sql 1970-01-01 00:00:00.000000000 +0000 @@ -1,30 +0,0 @@ -CREATE TABLE "hub"."repositories" -( - "id" BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - "name" CHARACTER VARYING(64) NOT NULL, - "owner" UUID NOT NULL, - "is_private" BOOLEAN NOT NULL DEFAULT FALSE, - "description" CHARACTER VARYING(254), - "vcs_type" CHARACTER VARYING(16) NOT NULL, - "website" TEXT, - "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, - "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, - CONSTRAINT "repositories_unique_owner_name" UNIQUE ("owner", "name"), - CONSTRAINT "repositories_fk_uid" FOREIGN KEY ("owner") - REFERENCES "hub"."accounts" ("uid") ON UPDATE CASCADE ON DELETE CASCADE -) -WITH ( - OIDS=FALSE -); - -COMMENT ON TABLE "hub"."repositories" IS 'All repositories i.e. their metadata are stored within this table. The combination of owner id and repository name must always be unique.'; -COMMENT ON COLUMN "hub"."repositories"."id" IS 'An auto generated primary key.'; -COMMENT ON COLUMN "hub"."repositories"."name" IS 'A repository name must start with a letter or number and must contain only alphanumeric ASCII characters as well as minus or underscore signs. It must be between 2 and 64 characters long.'; -COMMENT ON COLUMN "hub"."repositories"."owner" IS 'The unique ID of the user account that owns the repository.'; -COMMENT ON COLUMN "hub"."repositories"."is_private" IS 'A flag indicating if this repository is private i.e. only visible / accessible for accounts with appropriate permissions.'; -COMMENT ON COLUMN "hub"."repositories"."description" IS 'An optional short text description of the repository.'; -COMMENT ON COLUMN "hub"."repositories"."vcs_type" IS 'The type of the underlying DVCS that manages the repository.'; -COMMENT ON COLUMN "hub"."repositories"."website" IS 'An optional uri pointing to a website related to the repository / project.'; -COMMENT ON COLUMN "hub"."repositories"."created_at" IS 'The timestamp of when the repository was created.'; -COMMENT ON COLUMN "hub"."repositories"."updated_at" IS 'A timestamp when the repository was last changed.'; - diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/V3__fork_tables.sql new-smederee/modules/hub/src/main/resources/db/migration/V3__fork_tables.sql --- old-smederee/modules/hub/src/main/resources/db/migration/V3__fork_tables.sql 2025-01-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/resources/db/migration/V3__fork_tables.sql 1970-01-01 00:00:00.000000000 +0000 @@ -1,18 +0,0 @@ -CREATE TABLE "hub"."forks" -( - "original_repo" BIGINT NOT NULL, - "forked_repo" BIGINT NOT NULL, - CONSTRAINT "forks_pk" PRIMARY KEY ("original_repo", "forked_repo"), - CONSTRAINT "forks_fk_original_repo" FOREIGN KEY ("original_repo") - REFERENCES "hub"."repositories" ("id") ON UPDATE CASCADE ON DELETE CASCADE, - CONSTRAINT "forks_fk_forked_repo" FOREIGN KEY ("forked_repo") - REFERENCES "hub"."repositories" ("id") ON UPDATE CASCADE ON DELETE CASCADE -) -WITH ( - OIDS=FALSE -); - -COMMENT ON TABLE "hub"."forks" IS 'Stores fork relationships between repositories.'; -COMMENT ON COLUMN "hub"."forks"."original_repo" IS 'The ID of the original repository from which was forked.'; -COMMENT ON COLUMN "hub"."forks"."forked_repo" IS 'The ID of the repository which is the fork.'; - diff -rN -u old-smederee/modules/hub/src/main/resources/reference.conf new-smederee/modules/hub/src/main/resources/reference.conf --- old-smederee/modules/hub/src/main/resources/reference.conf 2025-01-31 10:46:28.154967279 +0000 +++ new-smederee/modules/hub/src/main/resources/reference.conf 2025-01-31 10:46:28.174967313 +0000 @@ -2,163 +2,165 @@ ### Reference configuration file for the Smederee hub service. ### ############################################################################### -# Configuration of the database. -# Defaults are given except for password and can also be overridden via -# environment variables. -database { - # The class name of the JDBC driver to be used. - driver = "org.postgresql.Driver" - driver = ${?SMEDEREE_HUB_DB_DRIVER} - # The JDBC connection URL **without** username and password. - url = "jdbc:postgresql://localhost:5432/smederee" - url = ${?SMEDEREE_HUB_DB_URL} - # The username (login) needed to authenticate against the database. - user = "smederee_hub" - user = ${?SMEDEREE_HUB_DB_USER} - # The password needed to authenticate against the database. - pass = ${?SMEDEREE_HUB_DB_PASS} -} - -# The general service configuration. -# Settings which toggle something on or off are booleans (true / false). -service { - # The hostname on which the service shall listen for requests. - host = "localhost" - # The TCP port number on which the service shall listen for requests. - port = 8080 - # A directory into which files are written that are supposed to be downloaded by users (e.g. distribution - # files of repositories). - download-directory = /var/tmp/smederee/download - download-directory = ${?SMEDEREE_DOWNLOAD_DIR} - # A file which contains the key used to build the CSRF protection. - # If it does not exist then it should be created with sensible permissions. - csrf-key-file = /var/tmp/smederee/csrf-key.bin - csrf-key-file = ${?SMEDEREE_CSRF_KEY_FILE} - - # Settings affecting how the service will communicate several information to - # the "outside world" e.g. if it runs behind a reverse proxy. - external { - # The official hostname of the service which will be used for the CSRF - # protection, generation of links in e-mails etc. - host = ${service.host} - - # A possible path prefix that will be prepended to any paths used in link - # generation. If no path prefix is used then you MUST either comment it out - # or set it to `path = null`! - #path = null - - # The port number which defaults to the port the service is listening on. - # Please note that this is also relevant for CSRF protection! - # If the service is running behind a reverse proxy on a standard port e.g. - # 80 or 443 (http or https) then you MUST set this either to `port = null` - # or comment it out! - port = ${service.port} - - # The URL scheme which is used for links and will also determine if cookies - # will have the secure flag enabled. - # Valid options are: - # - http - # - https - scheme = "http" - } - - # Authentication / login settings - authentication { - enabled = true - - # The secret used for the cookie encryption and validation. - # Using the default should produce a warning message on startup. - cookie-secret = "CHANGEME" - - # Determines after how many failed login attempts an account gets locked. - lock-after = 5 - - # Timeouts for the authentication session. - timeouts { - # The maximum allowed age an authentication session. This setting will - # affect the invalidation of a session on the server side. - # This timeout MUST be triggered regardless of session activity. - absolute-timeout = 3 days - - # This timeout defines how long after the last activity a session will - # remain valid. - idle-timeout = 30 minutes - - # The time after which a session will be renewed (a new session ID will be - # generated). - renewal-timeout = 20 minutes - } - } - - # Billing / payment related settings - billing { - enabled = false - - # Settings for the Stripe API used for billing. - stripe { - api-key = ${?STRIPE_API_KEY} - secret-key = ${?STRIPE_SECRET_KEY} - } - } - - # Configuration for the darcs module for vcs related operations via darcs. - darcs { - # The directory used to store the actual repositories structured after owner. - # ``` - # repositories-directory - # \_ user1 - # \_ repo1 - # \_ repo2 - # \_ user2 - # \_ repo1 - # ``` - repositories-directory = /srv/smederee/darcs - repositories-directory = ${?SMEDEREE_DARCS_REPOS_DIR} - # The path to the darcs binary executable. If not a full path (i.e. just - # `darcs`) it must be present on the `$PATH` of the environment under which - # the server is running. - executable = "darcs" - executable = ${?SMEDEREE_DARCS_EXECUTABLE} - } - - # The email middleware configuration for sending email messages. - email { - # The hostname of the email server (SMTP) to connect to. - host = "localhost" - host = ${?EMAIL_HOST} - # The port number to be used for the connection. - # This is usually 25 for local sendmail connections and 465 for SMTPS or 587 SMTP_TLS connections. - port = 25 - port = ${?EMAIL_PORT} - # Specify the transport method (security) to be used for the connection (should either be SMTPS or TLS). - transport = "PLAIN" - transport = ${?EMAIL_TRANSPORT} - # An optional username if authentication is required. - username = ${?EMAIL_USERNAME} - # An optional password if authentication is required. - password = ${?EMAIL_PASSWORD} - } - - # SSH server component settings - ssh { - enabled = false - # A username for generic access to services for darcs clone, pull and push - # (e.g. `darcs pull genericUser@smederee-domain:accountName/repository`). - generic-user = "darcs" - # The hostname/address the SSH server will bind to. - host = "localhost" - host = ${?SSH_SERVER_HOST} - # The port number on which the SSH server will listen. - port = 30983 - port = ${?SSH_SERVER_PORT} - # A path to the file from which the server key is loaded and also written to if it needs to be generated. - # This file should only be accessible for the user account that runs the smederee service. - server-key-file = /var/db/smederee/server.key - server-key-file = ${?SSH_SERVER_KEY} - } - - # Signup / registration related settings. - signup { - enabled = true +hub { + # Configuration of the database. + # Defaults are given except for password and can also be overridden via + # environment variables. + database { + # The class name of the JDBC driver to be used. + driver = "org.postgresql.Driver" + driver = ${?SMEDEREE_HUB_DB_DRIVER} + # The JDBC connection URL **without** username and password. + url = "jdbc:postgresql://localhost:5432/smederee" + url = ${?SMEDEREE_HUB_DB_URL} + # The username (login) needed to authenticate against the database. + user = "smederee_hub" + user = ${?SMEDEREE_HUB_DB_USER} + # The password needed to authenticate against the database. + pass = ${?SMEDEREE_HUB_DB_PASS} + } + + # The general service configuration. + # Settings which toggle something on or off are booleans (true / false). + service { + # The hostname on which the service shall listen for requests. + host = "localhost" + # The TCP port number on which the service shall listen for requests. + port = 8080 + # A directory into which files are written that are supposed to be downloaded by users (e.g. distribution + # files of repositories). + download-directory = /var/tmp/smederee/download + download-directory = ${?SMEDEREE_DOWNLOAD_DIR} + # A file which contains the key used to build the CSRF protection. + # If it does not exist then it should be created with sensible permissions. + csrf-key-file = /var/tmp/smederee/csrf-key.bin + csrf-key-file = ${?SMEDEREE_CSRF_KEY_FILE} + + # Settings affecting how the service will communicate several information to + # the "outside world" e.g. if it runs behind a reverse proxy. + external { + # The official hostname of the service which will be used for the CSRF + # protection, generation of links in e-mails etc. + host = ${hub.service.host} + + # A possible path prefix that will be prepended to any paths used in link + # generation. If no path prefix is used then you MUST either comment it out + # or set it to `path = null`! + #path = null + + # The port number which defaults to the port the service is listening on. + # Please note that this is also relevant for CSRF protection! + # If the service is running behind a reverse proxy on a standard port e.g. + # 80 or 443 (http or https) then you MUST set this either to `port = null` + # or comment it out! + port = ${hub.service.port} + + # The URL scheme which is used for links and will also determine if cookies + # will have the secure flag enabled. + # Valid options are: + # - http + # - https + scheme = "http" + } + + # Authentication / login settings + authentication { + enabled = true + + # The secret used for the cookie encryption and validation. + # Using the default should produce a warning message on startup. + cookie-secret = "CHANGEME" + + # Determines after how many failed login attempts an account gets locked. + lock-after = 5 + + # Timeouts for the authentication session. + timeouts { + # The maximum allowed age an authentication session. This setting will + # affect the invalidation of a session on the server side. + # This timeout MUST be triggered regardless of session activity. + absolute-timeout = 3 days + + # This timeout defines how long after the last activity a session will + # remain valid. + idle-timeout = 30 minutes + + # The time after which a session will be renewed (a new session ID will be + # generated). + renewal-timeout = 20 minutes + } + } + + # Billing / payment related settings + billing { + enabled = false + + # Settings for the Stripe API used for billing. + stripe { + api-key = ${?STRIPE_API_KEY} + secret-key = ${?STRIPE_SECRET_KEY} + } + } + + # Configuration for the darcs module for vcs related operations via darcs. + darcs { + # The directory used to store the actual repositories structured after owner. + # ``` + # repositories-directory + # \_ user1 + # \_ repo1 + # \_ repo2 + # \_ user2 + # \_ repo1 + # ``` + repositories-directory = /srv/smederee/darcs + repositories-directory = ${?SMEDEREE_DARCS_REPOS_DIR} + # The path to the darcs binary executable. If not a full path (i.e. just + # `darcs`) it must be present on the `$PATH` of the environment under which + # the server is running. + executable = "darcs" + executable = ${?SMEDEREE_DARCS_EXECUTABLE} + } + + # The email middleware configuration for sending email messages. + email { + # The hostname of the email server (SMTP) to connect to. + host = "localhost" + host = ${?EMAIL_HOST} + # The port number to be used for the connection. + # This is usually 25 for local sendmail connections and 465 for SMTPS or 587 SMTP_TLS connections. + port = 25 + port = ${?EMAIL_PORT} + # Specify the transport method (security) to be used for the connection (should either be SMTPS or TLS). + transport = "PLAIN" + transport = ${?EMAIL_TRANSPORT} + # An optional username if authentication is required. + username = ${?EMAIL_USERNAME} + # An optional password if authentication is required. + password = ${?EMAIL_PASSWORD} + } + + # SSH server component settings + ssh { + enabled = false + # A username for generic access to services for darcs clone, pull and push + # (e.g. `darcs pull genericUser@smederee-domain:accountName/repository`). + generic-user = "darcs" + # The hostname/address the SSH server will bind to. + host = "localhost" + host = ${?SSH_SERVER_HOST} + # The port number on which the SSH server will listen. + port = 30983 + port = ${?SSH_SERVER_PORT} + # A path to the file from which the server key is loaded and also written to if it needs to be generated. + # This file should only be accessible for the user account that runs the smederee service. + server-key-file = /var/db/smederee/server.key + server-key-file = ${?SSH_SERVER_KEY} + } + + # Signup / registration related settings. + signup { + enabled = true + } } } diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala 2025-01-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala 2025-01-31 10:46:28.174967313 +0000 @@ -19,6 +19,7 @@ import java.util.UUID +import de.smederee.security._ import de.smederee.ssh.PublicSshKey import fs2.Stream diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala 2025-01-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala 2025-01-31 10:46:28.174967313 +0000 @@ -31,7 +31,7 @@ import de.smederee.hub.RequestHelpers.instances.given import de.smederee.hub.config._ import de.smederee.hub.forms.types._ -import de.smederee.security.SignAndValidate +import de.smederee.security._ import de.smederee.ssh._ import org.http4s._ import org.http4s.dsl._ diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala 2025-01-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala 2025-01-31 10:46:28.174967313 +0000 @@ -18,133 +18,24 @@ package de.smederee.hub import java.nio.charset.StandardCharsets -import java.util.UUID import cats._ import cats.data._ import cats.syntax.all._ -import de.smederee.email.{ FromAddress, ToAddress } +import de.smederee.email.EmailAddress +import de.smederee.security._ +import de.smederee.tickets.ProjectOwner import org.springframework.security.crypto.argon2.Argon2PasswordEncoder import scala.util.matching.Regex -/** An email address must fulfil several format requirements which in detail should be looked up in the implementation. - */ -opaque type Email = String -object Email { - given Eq[Email] = Eq.fromUniversalEquals - - val validateString: Regex = - """^([a-zA-Z0-9.!#$%&’'*+/=?^_`{|}~-]+)@([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*)$""".r - - /** Create an instance of Email from the given String type. - * - * @param source - * An instance of type String which will be returned as a Email. - * @return - * The appropriate instance of Email. - */ - def apply(source: String): Email = source - - /** Try to create an instance of Email from the given String. - * - * @param source - * A String that should fulfil the requirements to be converted into a Email. - * @return - * An option to the successfully converted Email. - */ - def from(source: String): Option[Email] = { - // Must obviously not be null or empty. - val notEmpty = Option(source) - // Must have at least one '@' sign. - val hasAtChar = Option(source).filter(_.exists(_ === '@')) - // Must have at least one '.' character. - val hasDotChar = Option(source).filter(_.exists(_ === '.')) - // The maximum allowed length is 128 characters. - val belowMaxLength = Option(source).filter(_.length <= 128) - // The last part of the email address (usually the top level domain) must at least be 2 characters long. - val lastPartValid = Option(source) match { - case None => None - case Some(string) => - val parts = string.split('.') - if (parts.lastOption.map(_.length >= 2).getOrElse(false)) - Option(string) - else - None - } - // Check against a regular expression. TODO Maybe we can skip the other tests alltogether? - val passesRegularExpression = Option(source).filter(validateString.matches) - // Validate pre-conditions - (notEmpty, hasAtChar, hasDotChar, belowMaxLength, lastPartValid, passesRegularExpression).mapN { - case (email, _, _, _, _, _) => email - } - } - - extension (email: Email) { - - /** Convert the email into a [[de.smederee.email.FromAddress]] to be useable within the email middleware. - * - * @return - * The email address to be used in a `From` header. - */ - def toFromAddress: FromAddress = FromAddress(email.toString) - - /** Convert the email into a [[de.smederee.email.ToAddress]] to be useable within the email middleware. - * - * @return - * The email address to be used in a `To` header. - */ - def toToAddress: ToAddress = ToAddress(email.toString) - } - -} - -/** A password is stored as an `Array[Byte]` internally and its `validate(source: String)` function will check that the - * input has a minimum length. +/** Extension methods for a password instance. + * + * TODO: Refactor this into the security package! + * + * @param p + * A password. */ -opaque type Password = Array[Byte] -object Password { - - /** Create an instance of Password from the given Array[Byte] type. - * - * @param source - * An instance of type Array[Byte] which will be returned as a Password. - * @return - * The appropriate instance of Password. - */ - def apply(source: Array[Byte]): Password = source - - /** Try to create an instance of Password from the given String. - * - * @param source - * A String that should fulfil the requirements to be converted into a Password. - * @return - * An option to the successfully converted Password. - */ - def from(source: String): Option[Password] = - Option(source) match { - case None => None - case Some(string) => Option(string.getBytes(StandardCharsets.UTF_8)) - } - - /** Validate the given String against the criteria for a valid password. - * - * @param source - * A string which should fulfil the password criteria. - * @return - * Either a list of errors or the validated Password. - */ - def validate(source: String): ValidatedNec[String, Password] = - Option(source).map(_.trim.nonEmpty) match { - case Some(true) => - if (source.trim.length < 12) - "Password must be at least 12 characters long!".invalidNec - else - source.trim.getBytes(StandardCharsets.UTF_8).validNec - case _ => "Password must not be empty!".invalidNec - } -} - extension (p: Password) { /** Encode the password using the argon2 algorithm. @@ -154,7 +45,7 @@ */ def encode: PasswordHash = { val encoder = PasswordEncoder.Argon2 - PasswordHash(encoder.encode(new String(p, StandardCharsets.UTF_8))) + PasswordHash(encoder.encode(new String(p.toArray, StandardCharsets.UTF_8))) } /** Verify if the password matches the given `PasswordHash`. @@ -166,10 +57,8 @@ */ def matches(hash: PasswordHash): Boolean = { val encoder = PasswordEncoder.Argon2 - encoder.matches(new String(p, StandardCharsets.UTF_8), hash) + encoder.matches(new String(p.toArray, StandardCharsets.UTF_8), hash.toString) } - - def toArray: Array[Byte] = p } /** Initialises and holds our preferred password encoding algorithm. @@ -186,33 +75,6 @@ } -opaque type PasswordHash = String -object PasswordHash { - given Eq[PasswordHash] = Eq.fromUniversalEquals - - /** Create an instance of PasswordHash from the given String type. - * - * @param source - * An instance of type String which will be returned as a PasswordHash. - * @return - * The appropriate instance of PasswordHash. - */ - def apply(source: String): PasswordHash = source - - /** Try to create an instance of PasswordHash from the given String. - * - * @param source - * A String that should fulfil the requirements to be converted into a PasswordHash. - * @return - * An option to the successfully converted PasswordHash. - */ - def from(source: String): Option[PasswordHash] = - Option(source).map(_.trim.nonEmpty) match { - case Some(true) => Option(source.trim) - case _ => None - } -} - opaque type ResetToken = String object ResetToken { val Format: Regex = "^[a-zA-z0-9]+".r @@ -281,67 +143,6 @@ } -/** A username for an account has to obey several restrictions which are similiar to the ones found for Unix usernames. - * It must start with a letter, contain only alphanumeric characters, be at least 2 and at most 31 characters long and - * be all lowercase. - */ -opaque type Username = String -object Username { - given Eq[Username] = Eq.fromUniversalEquals - - val isAlphanumeric = "^[a-z][a-z0-9]+$".r - - /** Create an instance of Username from the given String type. - * - * @param source - * An instance of type String which will be returned as a Username. - * @return - * The appropriate instance of Username. - */ - def apply(source: String): Username = source - - /** Try to create an instance of Username from the given String. - * - * @param source - * A String that should fulfil the requirements to be converted into a Username. - * @return - * An option to the successfully converted Username. - */ - def from(s: String): Option[Username] = validate(s).toOption - - /** Validate the given string and return either the validated username or a list of errors. - * - * @param s - * An arbitrary string which should be a username. - * @return - * Either a list of errors or the validated username. - */ - def validate(s: String): ValidatedNec[String, Username] = - Option(s).map(_.trim.nonEmpty) match { - case Some(true) => - val input = s.trim - val miniumLength = - if (input.length >= 2) - input.validNec - else - "Username too short (min. 2 characters)!".invalidNec - val maximumLength = - if (input.length < 32) - input.validNec - else - "Username too long (max. 31 characters)!".invalidNec - val alphanumeric = - if (isAlphanumeric.matches(input)) - input.validNec - else - "Username must be all lowercase alphanumeric characters and start with a character.".invalidNec - (miniumLength, maximumLength, alphanumeric).mapN { case (_, _, name) => - name - } - case _ => "Username must not be empty!".invalidNec - } -} - /** Extractor to retrieve an Username from a path parameter. */ object UsernamePathParameter { @@ -406,7 +207,7 @@ * @param validatedEmail * This flag indicates if the email address of the user has been validated via a validation email. */ -final case class Account(uid: UserId, name: Username, email: Email, validatedEmail: Boolean) +final case class Account(uid: UserId, name: Username, email: EmailAddress, validatedEmail: Boolean) object Account { given Eq[Account] = @@ -416,6 +217,13 @@ extension (account: Account) { + /** Create project owner metadata from the account. + * + * @return + * Descriptive information about the owner of a project based on the account. + */ + def toProjectOwner: ProjectOwner = ProjectOwner(account.uid, account.name, account.email) + /** Create vcs repository owner metadata from the account. * * @return diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationMiddleware.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationMiddleware.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationMiddleware.scala 2025-01-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationMiddleware.scala 2025-01-31 10:46:28.174967313 +0000 @@ -18,7 +18,6 @@ package de.smederee.hub import java.time._ -import java.util.UUID import cats.data._ import cats.effect._ diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRepository.scala 2025-01-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRepository.scala 2025-01-31 10:46:28.174967313 +0000 @@ -17,6 +17,9 @@ package de.smederee.hub +import de.smederee.email.EmailAddress +import de.smederee.security._ + /** A base class for database functionality related to the authentication process. * * ### General notes ### @@ -67,7 +70,7 @@ * @return * An option to the found account if it exists. */ - def findAccountByEmail(email: Email): F[Option[Account]] + def findAccountByEmail(email: EmailAddress): F[Option[Account]] /** Search for the unlocked account with the given name in the database and return the first found result. * diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala 2025-01-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala 2025-01-31 10:46:28.174967313 +0000 @@ -18,26 +18,23 @@ package de.smederee.hub import java.time.{ OffsetDateTime, ZoneOffset } -import java.util.UUID import cats.data._ import cats.effect._ import cats.syntax.all._ import de.smederee.hub.RequestHelpers.instances.given_RequestHelpers_Request import de.smederee.hub.SessionHelpers.instances.toAuthenticationCookie +import de.smederee.hub._ import de.smederee.hub.config._ import de.smederee.hub.forms.types.FormErrors import de.smederee.hub.forms.types.FormFieldError -import de.smederee.hub.views -import de.smederee.security.SignAndValidate -import org.http4s.FormDataDecoder._ +import de.smederee.security._ import org.http4s._ import org.http4s.dsl.Http4sDsl import org.http4s.headers.Location import org.http4s.implicits._ import org.http4s.twirl.TwirlInstances._ import org.slf4j.LoggerFactory -import play.twirl.api._ /** Enumeration of possible kinds of authentication failures. */ diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/config/ConfigurationPath.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/config/ConfigurationPath.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/config/ConfigurationPath.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/config/ConfigurationPath.scala 2025-01-31 10:46:28.174967313 +0000 @@ -0,0 +1,45 @@ +/* + * 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.config + +/** A configuration path describes a path within a configuration file and is used to determine locations of certain + * configurations within a combined configuration file. + */ +opaque type ConfigurationPath = String +object ConfigurationPath { + + given Conversion[ConfigurationPath, String] = _.toString + + /** Create an instance of ConfigurationPath from the given String type. + * + * @param source + * An instance of type String which will be returned as a ConfigurationPath. + * @return + * The appropriate instance of ConfigurationPath. + */ + def apply(source: String): ConfigurationPath = source + + /** Try to create an instance of ConfigurationPath from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a ConfigurationPath. + * @return + * An option to the successfully converted ConfigurationPath. + */ + def from(source: String): Option[ConfigurationPath] = Option(source).map(_.trim).filter(_.nonEmpty) +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/config/SmedereeHubConfig.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/config/SmedereeHubConfig.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/config/SmedereeHubConfig.scala 2025-01-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/config/SmedereeHubConfig.scala 2025-01-31 10:46:28.174967313 +0000 @@ -21,45 +21,16 @@ import java.nio.file._ import cats.kernel.Eq -import cats.syntax.all._ import com.comcast.ip4s.{ Host, Port } import de.smederee.email._ import de.smederee.html.ExternalUrlConfiguration import de.smederee.security._ import de.smederee.ssh._ import org.http4s.Uri -import org.slf4j.LoggerFactory import pureconfig._ import scala.concurrent.duration.FiniteDuration import scala.util.Try -import scala.util.matching.Regex - -opaque type ConfigKey = String -object ConfigKey { - - /** Create an instance of ConfigKey from the given String type. - * - * @param source - * An instance of type String which will be returned as a ConfigKey. - * @return - * The appropriate instance of ConfigKey. - */ - def apply(source: String): ConfigKey = source - - /** Try to create an instance of ConfigKey from the given String. - * - * @param source - * A String that should fulfil the requirements to be converted into a ConfigKey. - * @return - * An option to the successfully converted ConfigKey. - */ - def from(source: String): Option[ConfigKey] = - Option(source).map(_.trim.nonEmpty) match { - case Some(true) => Option(source.trim) - case _ => None - } -} opaque type CookieName = String object CookieName { @@ -87,32 +58,6 @@ } } -opaque type CsrfToken = String -object CsrfToken { - - /** Create an instance of CsrfToken from the given String type. - * - * @param source - * An instance of type String which will be returned as a CsrfToken. - * @return - * The appropriate instance of CsrfToken. - */ - def apply(source: String): CsrfToken = source - - /** Try to create an instance of CsrfToken from the given String. - * - * @param source - * A String that should fulfil the requirements to be converted into a CsrfToken. - * @return - * An option to the successfully converted CsrfToken. - */ - def from(source: String): Option[CsrfToken] = - Option(source).map(_.trim.nonEmpty) match { - case Some(true) => Option(source.trim) - case _ => None - } -} - opaque type DirectoryPath = Path object DirectoryPath { @@ -180,44 +125,6 @@ } -opaque type LanguageCode = String -object LanguageCode { - val isIso639: Regex = "^[a-z]{2,3}$".r - - /** Create an instance of LanguageCode from the given String type. - * - * @param source - * An instance of type String which will be returned as a LanguageCode. - * @return - * The appropriate instance of LanguageCode. - */ - def apply(source: String): LanguageCode = source - - /** Try to create an instance of LanguageCode from the given String. - * - * @param source - * A String that should fulfil the requirements to be converted into a LanguageCode. - * @return - * An option to the successfully converted LanguageCode. - */ - def from(source: String): Option[LanguageCode] = - Option(source).map(isIso639.matches) match { - case Some(true) => Option(source) - case _ => None - } - - extension (code: LanguageCode) { - - /** Convert the language code into a [[java.util.Locale]] to be used for internationalisation functions. - * - * @return - * A locale retrieved via the `forLanguageTag` method. - */ - def toLocale: java.util.Locale = java.util.Locale.forLanguageTag(code.toString) - } - -} - /** Global constants which are used throughout the code. */ object Constants { @@ -236,6 +143,8 @@ final case class SmedereeHubConfig(database: DatabaseConfig, service: ServiceConfig) object SmedereeHubConfig { + val location: ConfigurationPath = ConfigurationPath("hub") + given Eq[SmedereeHubConfig] = Eq.fromUniversalEquals given ConfigReader[SmedereeHubConfig] = ConfigReader.forProduct2("database", "service")(SmedereeHubConfig.apply) @@ -255,11 +164,7 @@ final case class DatabaseConfig(driver: String, url: String, user: String, pass: String) object DatabaseConfig { - // The default configuration key under which to lookup the database configuration. - final val parentKey: ConfigKey = ConfigKey("database") - - given Eq[DatabaseConfig] = Eq.fromUniversalEquals - + given Eq[DatabaseConfig] = Eq.fromUniversalEquals given ConfigReader[DatabaseConfig] = ConfigReader.forProduct4("driver", "url", "user", "pass")(DatabaseConfig.apply) } @@ -306,9 +211,6 @@ ) object ServiceConfig { - // The default configuration key under which to lookup the service configuration. - final val parentKey: ConfigKey = ConfigKey("service") - given Eq[ServiceConfig] = Eq.fromUniversalEquals given ConfigReader[Host] = ConfigReader.fromStringOpt[Host](Host.fromString) @@ -337,13 +239,13 @@ "port", "csrf-key-file", "download-directory", - AuthenticationConfiguration.parentKey.toString, - BillingConfiguration.parentKey.toString, - DarcsConfiguration.parentKey.toString, + "authentication", + "billing", + "darcs", "email", "external", - SignupConfiguration.parentKey.toString, - SshServerConfiguration.parentKey.toString + "signup", + "ssh" )(ServiceConfig.apply) } @@ -364,14 +266,10 @@ ) object AuthenticationTimeouts { - // The default configuration key under which to lookup the billing configuration. - final val parentKey: ConfigKey = ConfigKey("timeouts") - given ConfigReader[AuthenticationTimeouts] = ConfigReader.forProduct3("absolute-timeout", "idle-timeout", "renewal-timeout")( AuthenticationTimeouts.apply ) - } /** Configuration for the authentication feature. @@ -393,11 +291,6 @@ ) object AuthenticationConfiguration { - private val log = LoggerFactory.getLogger(getClass) - - // The default configuration key under which to lookup the billing configuration. - final val parentKey: ConfigKey = ConfigKey("authentication") - given Eq[AuthenticationConfiguration] = Eq.fromUniversalEquals given ConfigReader[FailedAttempts] = @@ -426,9 +319,6 @@ final case class BillingConfiguration(enabled: Boolean) object BillingConfiguration { - // The default configuration key under which to lookup the billing configuration. - final val parentKey: ConfigKey = ConfigKey("billing") - given Eq[BillingConfiguration] = Eq.fromUniversalEquals given ConfigReader[BillingConfiguration] = ConfigReader.forProduct1("enabled")(BillingConfiguration.apply) @@ -453,9 +343,6 @@ final case class DarcsConfiguration(executable: Path, repositoriesDirectory: DirectoryPath) object DarcsConfiguration { - // The default configuration key under which to lookup the darcs configuration. - final val parentKey: ConfigKey = ConfigKey("darcs") - given Eq[DarcsConfiguration] = Eq.fromUniversalEquals given ConfigReader[DirectoryPath] = ConfigReader.fromStringOpt(DirectoryPath.fromString) @@ -472,9 +359,6 @@ final case class SignupConfiguration(enabled: Boolean) object SignupConfiguration { - // The default configuration key under which to lookup the signup configuration. - final val parentKey: ConfigKey = ConfigKey("signup") - given Eq[SignupConfiguration] = Eq.fromUniversalEquals given ConfigReader[Uri] = ConfigReader.fromStringOpt(s => Uri.fromString(s).toOption) diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DatabaseMigrator.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DatabaseMigrator.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DatabaseMigrator.scala 2025-01-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DatabaseMigrator.scala 2025-01-31 10:46:28.174967313 +0000 @@ -61,6 +61,6 @@ * An instance configuration which can be turned into a flyway database migrator by calling the `.load()` method. */ def configureFlyway(url: String, user: String, pass: String): FluentConfiguration = - Flyway.configure().defaultSchema("hub").dataSource(url, user, pass) + Flyway.configure().defaultSchema("hub").locations("classpath:db/migration/hub").dataSource(url, user, pass) } diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala 2025-01-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala 2025-01-31 10:46:28.174967313 +0000 @@ -20,18 +20,16 @@ import java.util.UUID import cats.effect._ -import cats.syntax.all._ -import de.smederee.hub.VcsMetadataRepositoriesOrdering._ +import de.smederee.email.EmailAddress +import de.smederee.security._ import de.smederee.ssh._ import doobie._ -import doobie.Fragments._ import doobie.implicits._ import doobie.postgres.implicits._ import fs2.Stream -import org.http4s.Uri final class DoobieAccountManagementRepository[F[_]: Sync](tx: Transactor[F]) extends AccountManagementRepository[F] { - given Meta[Email] = Meta[String].timap(Email.apply)(_.toString) + given Meta[EmailAddress] = Meta[String].timap(EmailAddress.apply)(_.toString) given Meta[EncodedKeyBytes] = Meta[String].timap(EncodedKeyBytes.unsafeFrom)(_.toString) given Meta[KeyComment] = Meta[String].timap(KeyComment.apply)(_.toString) given Meta[KeyFingerprint] = Meta[String].timap(KeyFingerprint.apply)(_.toString) diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAuthenticationRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAuthenticationRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAuthenticationRepository.scala 2025-01-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAuthenticationRepository.scala 2025-01-31 10:46:28.174967313 +0000 @@ -20,13 +20,15 @@ import java.util.UUID import cats.effect._ +import de.smederee.email.EmailAddress +import de.smederee.security._ import doobie._ import doobie.Fragments._ import doobie.implicits._ import doobie.postgres.implicits._ final class DoobieAuthenticationRepository[F[_]: Sync](tx: Transactor[F]) extends AuthenticationRepository[F] { - given Meta[Email] = Meta[String].timap(Email.apply)(_.toString) + given Meta[EmailAddress] = Meta[String].timap(EmailAddress.apply)(_.toString) given Meta[PasswordHash] = Meta[String].timap(PasswordHash.apply)(_.toString) given Meta[SessionId] = Meta[String].timap(SessionId.apply)(_.toString) given Meta[UnlockToken] = Meta[String].timap(UnlockToken.apply)(_.toString) @@ -50,7 +52,7 @@ query.query[Account].option.transact(tx) } - override def findAccountByEmail(email: Email): F[Option[Account]] = { + override def findAccountByEmail(email: EmailAddress): F[Option[Account]] = { val emailFilter = fr"""email = $email""" val query = selectAccountColumns ++ whereAnd(notLockedFilter, emailFilter) ++ fr"""LIMIT 1""" query.query[Account].option.transact(tx) diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieSignupRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieSignupRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieSignupRepository.scala 2025-01-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieSignupRepository.scala 2025-01-31 10:46:28.174967313 +0000 @@ -20,13 +20,15 @@ import java.util.UUID import cats.effect._ +import de.smederee.email.EmailAddress +import de.smederee.security._ import doobie._ import doobie.implicits._ import doobie.postgres.implicits._ final class DoobieSignupRepository[F[_]: Sync](tx: Transactor[F]) extends SignupRepository[F] { - given Meta[Email] = Meta[String].timap(Email.apply)(_.toString) + given Meta[EmailAddress] = Meta[String].timap(EmailAddress.apply)(_.toString) given Meta[PasswordHash] = Meta[String].timap(PasswordHash.apply)(_.toString) given Meta[UserId] = Meta[UUID].timap(UserId.apply)(_.toUUID) given Meta[Username] = Meta[String].timap(Username.apply)(_.toString) @@ -35,8 +37,8 @@ sql"""INSERT INTO "hub"."accounts" (uid, name, email, password, created_at, updated_at, validated_email) VALUES(${account.uid}, ${account.name}, ${account.email}, $hash, NOW(), NOW(), ${account.validatedEmail})""".update.run .transact(tx) - override def findEmail(address: Email): F[Option[Email]] = - sql"""SELECT email FROM "hub"."accounts" WHERE email = $address""".query[Email].option.transact(tx) + override def findEmail(address: EmailAddress): F[Option[EmailAddress]] = + sql"""SELECT email FROM "hub"."accounts" WHERE email = $address""".query[EmailAddress].option.transact(tx) override def findUsername(name: Username): F[Option[Username]] = sql"""SELECT name FROM "hub"."accounts" WHERE name = $name""".query[Username].option.transact(tx) diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala 2025-01-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala 2025-01-31 10:46:28.174967313 +0000 @@ -22,6 +22,7 @@ import cats.effect._ import cats.syntax.all._ import de.smederee.hub.VcsMetadataRepositoriesOrdering._ +import de.smederee.security.{ UserId, Username } import doobie._ import doobie.Fragments._ import doobie.implicits._ 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-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala 2025-01-31 10:46:28.174967313 +0000 @@ -33,6 +33,8 @@ import de.smederee.hub.config._ import de.smederee.security._ import de.smederee.ssh._ +import de.smederee.tickets.config._ +import de.smederee.tickets._ import doobie._ import org.http4s._ import org.http4s.dsl.io._ @@ -92,16 +94,25 @@ def run(args: List[String]): IO[ExitCode] = { val databaseMigrator = new DatabaseMigrator[IO] for { - configuration <- IO( - ConfigSource.fromConfig(ConfigFactory.load(getClass.getClassLoader)).loadOrThrow[SmedereeHubConfig] + 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, configuration.service.authentication.cookieSecret.toArray)) + 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 = configuration.service.darcs.repositoriesDirectory.toPath + val repositoriesDirectory = hubConfiguration.service.darcs.repositoriesDirectory.toPath if (Files.exists(repositoriesDirectory)) { if (Files.isDirectory(repositoriesDirectory)) { Right(s"Using repositories directory at: $repositoriesDirectory") @@ -122,19 +133,19 @@ case Right(message) => IO(log.info(message)) } _ <- databaseMigrator.migrate( - configuration.database.url, - configuration.database.user, - configuration.database.pass + hubConfiguration.database.url, + hubConfiguration.database.user, + hubConfiguration.database.pass ) transactor = Transactor.fromDriverManager[IO]( - configuration.database.driver, - configuration.database.url, - configuration.database.user, - configuration.database.pass + hubConfiguration.database.driver, + hubConfiguration.database.url, + hubConfiguration.database.user, + hubConfiguration.database.pass ) cryptoClock = java.time.Clock.systemUTC - csrfKey <- loadOrCreateCsrfKey(configuration.service.csrfKeyFile) - csrfOriginCheck = createCsrfOriginCheck(configuration.service.external) + csrfKey <- loadOrCreateCsrfKey(hubConfiguration.service.csrfKeyFile) + csrfOriginCheck = createCsrfOriginCheck(hubConfiguration.service.external) 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. @@ -143,48 +154,48 @@ */ csrfMiddleware = csrfBuilder .withClock(cryptoClock) - .withCookieDomain(Option(configuration.service.external.host.toString)) + .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(configuration.service.external.createFullUri(uri"/")))), + headers = Headers(List(headers.Location(hubConfiguration.service.external.createFullUri(uri"/")))), status = Status.SeeOther ).removeCookie(Constants.csrfCookieName.toString) ) .build - signAndValidate = SignAndValidate(configuration.service.authentication.cookieSecret) + signAndValidate = SignAndValidate(hubConfiguration.service.authentication.cookieSecret) assetsRoutes = resourceServiceBuilder[IO](Constants.assetsPath.path.toAbsolute.toString).toRoutes authenticationRepo = new DoobieAuthenticationRepository[IO](transactor) authenticationWithFallThrough = AuthMiddleware.withFallThrough( authenticateUserWithFallThrough( authenticationRepo, signAndValidate, - configuration.service.authentication.timeouts + hubConfiguration.service.authentication.timeouts ) ) - darcsWrapper = new DarcsCommands[IO](configuration.service.darcs.executable) - emailMiddleware = new SimpleJavaMailMiddleware(configuration.service.email) + darcsWrapper = new DarcsCommands[IO](hubConfiguration.service.darcs.executable) + emailMiddleware = new SimpleJavaMailMiddleware(hubConfiguration.service.email) accountManagementRepo = new DoobieAccountManagementRepository[IO](transactor) accountManagementRoutes = new AccountManagementRoutes[IO]( accountManagementRepo, - configuration.service, + hubConfiguration.service, emailMiddleware, signAndValidate ) authenticationRoutes = new AuthenticationRoutes[IO]( cryptoClock, - configuration.service.authentication, + hubConfiguration.service.authentication, authenticationRepo, signAndValidate ) signUpRepo = new DoobieSignupRepository[IO](transactor) - signUpRoutes = new SignupRoutes[IO](configuration.service, signUpRepo) - landingPages = new LandingPageRoutes[IO](configuration.service) + signUpRoutes = new SignupRoutes[IO](hubConfiguration.service, signUpRepo) + landingPages = new LandingPageRoutes[IO](hubConfiguration.service) vcsMetadataRepo = new DoobieVcsMetadataRepository[IO](transactor) vcsRepoRoutes = new VcsRepositoryRoutes[IO]( - configuration.service, + hubConfiguration.service, darcsWrapper, vcsMetadataRepo ) @@ -205,10 +216,16 @@ landingPages.routes) ).orNotFound // Create our ssh server fiber (or a dummy one if disabled). - sshServerProvider = configuration.service.ssh.enabled match { + sshServerProvider = hubConfiguration.service.ssh.enabled match { case false => None case true => - Option(new SshServerProvider(configuration.service.darcs, configuration.database, configuration.service.ssh)) + Option( + new SshServerProvider( + hubConfiguration.service.darcs, + hubConfiguration.database, + hubConfiguration.service.ssh + ) + ) } sshServer = sshServerProvider.fold(IO.unit.as(ExitCode.Success))( _.run().use(server => @@ -220,8 +237,8 @@ // Create our webserver fiber. resource = EmberServerBuilder .default[IO] - .withHost(configuration.service.host) - .withPort(configuration.service.port) + .withHost(hubConfiguration.service.host) + .withPort(hubConfiguration.service.port) .withHttpApp(csrfMiddleware.validate()(hubWebService)) .build webServer = resource.use(server => diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/LoginForm.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/LoginForm.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/LoginForm.scala 2025-01-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/LoginForm.scala 2025-01-31 10:46:28.174967313 +0000 @@ -21,6 +21,7 @@ import cats.syntax.all._ import de.smederee.hub.forms._ import de.smederee.hub.forms.types._ +import de.smederee.security._ /** A data container for the login form. * 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-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/MarkdownRenderer.scala 2025-01-31 10:46:28.174967313 +0000 @@ -115,7 +115,7 @@ val text = node.asInstanceOf[Text] // We only receive text nodes (see `getNodeTypes`). isToDoItem.findFirstMatchIn(text.getLiteral()) match { case Some(matchedItem) => - log.info(s"MATCH: ${text.getLiteral()} (${matchedItem.groupCount})") + 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) diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/RequestHelpers.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/RequestHelpers.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/RequestHelpers.scala 2025-01-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/RequestHelpers.scala 2025-01-31 10:46:28.174967313 +0000 @@ -18,8 +18,8 @@ package de.smederee.hub import cats.syntax.all._ -import de.smederee.hub.config.{ Constants, CsrfToken } -import de.smederee.security.SignedToken +import de.smederee.hub.config.Constants +import de.smederee.security.{ CsrfToken, SignedToken } import org.http4s._ trait RequestHelpers[A] { diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/Session.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/Session.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/Session.scala 2025-01-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/Session.scala 2025-01-31 10:46:28.174967313 +0000 @@ -21,6 +21,7 @@ import cats._ import cats.syntax.all._ +import de.smederee.security.UserId /** A user session which is used to track logged in users. * diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupForm.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupForm.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupForm.scala 2025-01-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupForm.scala 2025-01-31 10:46:28.174967313 +0000 @@ -19,8 +19,10 @@ import cats.data._ import cats.syntax.all._ +import de.smederee.email.EmailAddress import de.smederee.hub.forms._ import de.smederee.hub.forms.types._ +import de.smederee.security._ /** A data container for our signup form. * @@ -33,17 +35,17 @@ * @param password * The password of the user. */ -final case class SignupForm(name: Username, email: Email, password: Password) +final case class SignupForm(name: Username, email: EmailAddress, password: Password) object SignupForm extends FormValidator[SignupForm] { val fieldName: FormField = FormField("name") val fieldEmail: FormField = FormField("email") val fieldPassword: FormField = FormField("password") override def validate(data: Map[String, String]): ValidatedNec[FormErrors, SignupForm] = { - val email: ValidatedNec[FormErrors, Email] = data + val email: ValidatedNec[FormErrors, EmailAddress] = data .get(fieldEmail) .fold(FormFieldError("No email address given!").invalidNec)(s => - Email.from(s).fold(FormFieldError("Invalid email address!").invalidNec)(_.validNec) + EmailAddress.from(s).fold(FormFieldError("Invalid email address!").invalidNec)(_.validNec) ) .leftMap(es => NonEmptyChain.of(Map(fieldEmail -> es.toList))) val name: ValidatedNec[FormErrors, Username] = data diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRepository.scala 2025-01-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRepository.scala 2025-01-31 10:46:28.174967313 +0000 @@ -17,6 +17,9 @@ package de.smederee.hub +import de.smederee.email.EmailAddress +import de.smederee.security._ + /** A base class for our signup repository which provides the needed database functions. * * @tparam F @@ -43,7 +46,7 @@ * @return * An option which is either empty or contains the email address if it exists in the database. */ - def findEmail(address: Email): F[Option[Email]] + def findEmail(address: EmailAddress): F[Option[EmailAddress]] /** Find the given username in the database accounts table. This function can be used to check if a username has * already been taken. diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala 2025-01-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala 2025-01-31 10:46:28.174967313 +0000 @@ -17,8 +17,6 @@ package de.smederee.hub -import java.util.UUID - import cats.data._ import cats.effect._ import cats.syntax.all._ @@ -27,15 +25,13 @@ import de.smederee.hub.config._ import de.smederee.hub.forms.types.FormErrors import de.smederee.hub.forms.types.FormFieldError -import de.smederee.hub.views -import org.http4s.FormDataDecoder._ +import de.smederee.security._ import org.http4s._ import org.http4s.dsl.Http4sDsl import org.http4s.headers.Location import org.http4s.implicits._ import org.http4s.twirl.TwirlInstances._ import org.slf4j.LoggerFactory -import play.twirl.api._ /** The routes for handling the user signup process. * @@ -121,7 +117,7 @@ ) hash <- Sync[F].delay(signupForm.password.encode) _ <- Sync[F].delay(log.info(s"Going to create account for ${account.name}.")) - _ <- repo.createAccount(account, PasswordHash(hash)) + _ <- repo.createAccount(account, hash) redirect <- SeeOther(Location(signupUri.addPath("welcome"))) } yield redirect } diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/types.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/types.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/types.scala 2025-01-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/types.scala 2025-01-31 10:46:28.174967313 +0000 @@ -17,7 +17,7 @@ package de.smederee.hub -import java.util.{ Base64, UUID } +import java.util.Base64 import java.security.SecureRandom import cats.Eq @@ -101,56 +101,3 @@ base64Encoder.encodeToString(buffer) } } - -/** A user id is supposed to be globally unique and maps to the [[java.util.UUID]] type beneath. - */ -opaque type UserId = UUID -object UserId { - val Format = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$".r - - given Eq[UserId] = Eq.fromUniversalEquals - - /** Create an instance of UserId from the given UUID type. - * - * @param source - * An instance of type UUID which will be returned as a UserId. - * @return - * The appropriate instance of UserId. - */ - def apply(source: UUID): UserId = source - - /** Try to create an instance of UserId from the given UUID. - * - * @param source - * A UUID that should fulfil the requirements to be converted into a UserId. - * @return - * An option to the successfully converted UserId. - */ - def from(source: UUID): Option[UserId] = Option(source) - - /** Try to create an instance of UserId from the given String. - * - * @param source - * A String that should fulfil the requirements to be converted into a UserId. - * @return - * An option to the successfully converted UserId. - */ - def fromString(source: String): Either[String, UserId] = - Option(source) - .filter(s => Format.matches(s)) - .flatMap { uuidString => - Either.catchNonFatal(UUID.fromString(uuidString)).toOption - } - .toRight("Illegal value for UserId!") - - /** Generate a new random user id. - * - * @return - * A user id which is pseudo randomly generated. - */ - def randomUserId: UserId = UUID.randomUUID - - extension (uid: UserId) { - def toUUID: UUID = uid - } -} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala 2025-01-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala 2025-01-31 10:46:28.174967313 +0000 @@ -17,6 +17,7 @@ package de.smederee.hub +import de.smederee.security.Username import fs2.Stream /** A base class for a database repository that should handle all functionality regarding vcs repositories and their 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-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala 2025-01-31 10:46:28.174967313 +0000 @@ -30,6 +30,7 @@ import de.smederee.hub.config._ import de.smederee.hub.forms.types.FormErrors import de.smederee.html.LinkTools._ +import de.smederee.security.{ CsrfToken, Username } import de.smederee.ssh._ import org.commonmark.parser.Parser import org.commonmark.renderer.html.HtmlRenderer @@ -62,7 +63,7 @@ private val log = LoggerFactory.getLogger(getClass) private val createRepoPath = uri"/repo/create" - private val MaximumFileSize = 131072L // TODO Move to configuration directive. + private val MaximumFileSize = 131072L // TODO: Move to configuration directive. val darcsConfig = configuration.darcs val linkConfig = configuration.external @@ -100,7 +101,7 @@ case _ => None } } - // TODO Replace with whatever we implement as proper permission model. ;-) + // TODO: Replace with whatever we implement as proper permission model. ;-) repoAndId = currentUser match { case None => loadedRepo.filter(tuple => tuple._1.isPrivate === false) case Some(user) => @@ -266,7 +267,7 @@ linkConfig.createFullUri(Uri(path = Uri.Path.unsafeFromString(s"~$repositoriesOwnerName"))) ) resp <- owner match { - case None => // TODO Better error message... + case None => // TODO: Better error message... NotFound( views.html.showRepositories()( actionBaseUri, @@ -330,9 +331,17 @@ ) ) ) - vcsLog <- darcs.log(directory.toNIO)(repositoryName.toString)(Chain(s"--max-count=2", "--xml-output")) - xmlLog <- Sync[F].delay(scala.xml.XML.loadString(vcsLog.stdout.toList.mkString)) - patches <- Sync[F].delay((xmlLog \ "patch").flatMap(VcsRepositoryPatchMetadata.fromDarcsXmlLog).toList) + vcsLog <- darcs.log(directory.toNIO)(repositoryName.toString)(Chain(s"--max-count=2", "--xml-output")) + xmlLog <- Sync[F].delay(scala.xml.XML.loadString(vcsLog.stdout.toList.mkString)) + patches <- Sync[F].delay( + (xmlLog \ "patch").flatMap(VcsRepositoryPatchMetadata.fromDarcsXmlLog).toList.map { patch => + // TODO: Move the hard coded number into a configuration value. + if (patch.comment.exists(_.length > 320)) + patch.copy(comment = patch.comment.map(c => VcsPatchComment(c.toString.take(320) + "..."))) + else + patch + } + ) readmeData <- repo.traverse(repo => doLoadReadme(repo)) readme <- readmeData match { case Some((lines, Some(filename))) => diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala 2025-01-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala 2025-01-31 10:46:28.174967313 +0000 @@ -23,6 +23,7 @@ import cats._ import cats.data._ import cats.syntax.all._ +import de.smederee.security.{ UserId, Username } import org.http4s.Uri import org.slf4j.LoggerFactory @@ -202,6 +203,9 @@ */ def from(source: String): Option[VcsPatchComment] = Option(source).filter(_.nonEmpty) + extension (comment: VcsPatchComment) { + def length: Int = comment.length + } } opaque type VcsPatchFilename = String diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/ssh/DarcsSshCommand.scala new-smederee/modules/hub/src/main/scala/de/smederee/ssh/DarcsSshCommand.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/ssh/DarcsSshCommand.scala 2025-01-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/ssh/DarcsSshCommand.scala 2025-01-31 10:46:28.174967313 +0000 @@ -26,7 +26,8 @@ import cats.effect.unsafe.implicits.global import cats.syntax.all._ import de.smederee.hub.config._ -import de.smederee.hub.{ UserId, Username, VcsRepositoryName } +import de.smederee.hub.VcsRepositoryName +import de.smederee.security.{ UserId, Username } import org.apache.sshd.scp.common.ScpHelper import org.apache.sshd.scp.server._ import org.apache.sshd.server.channel.ChannelSession diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/ssh/DoobieSshAuthenticationRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/ssh/DoobieSshAuthenticationRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/ssh/DoobieSshAuthenticationRepository.scala 2025-01-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/ssh/DoobieSshAuthenticationRepository.scala 2025-01-31 10:46:28.178967320 +0000 @@ -21,6 +21,7 @@ import cats.effect._ import de.smederee.hub._ +import de.smederee.security.{ UserId, Username } import doobie._ import doobie.Fragments._ import doobie.implicits._ diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/ssh/PublicSshKey.scala new-smederee/modules/hub/src/main/scala/de/smederee/ssh/PublicSshKey.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/ssh/PublicSshKey.scala 2025-01-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/ssh/PublicSshKey.scala 2025-01-31 10:46:28.178967320 +0000 @@ -23,7 +23,7 @@ import cats._ import cats.syntax.all._ -import de.smederee.hub._ +import de.smederee.security.UserId import org.apache.sshd.common.config.keys.AuthorizedKeyEntry import org.bouncycastle.crypto.util.OpenSSHPublicKeyUtil diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshAuthenticationRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshAuthenticationRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshAuthenticationRepository.scala 2025-01-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshAuthenticationRepository.scala 2025-01-31 10:46:28.178967320 +0000 @@ -20,6 +20,7 @@ import java.util.UUID import de.smederee.hub._ +import de.smederee.security.Username /** The base class for needed repository functionality releated to ssh authentication like loading/providing keys. * diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshServer.scala new-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshServer.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshServer.scala 2025-01-31 10:46:28.158967286 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshServer.scala 2025-01-31 10:46:28.178967320 +0000 @@ -26,8 +26,8 @@ import cats.effect.std.Dispatcher import cats.syntax.all._ import com.comcast.ip4s._ -import de.smederee.hub.{ UserId, Username } import de.smederee.hub.config._ +import de.smederee.security.{ UserId, Username } import doobie._ import org.apache.sshd.common.AttributeRepository.AttributeKey import org.apache.sshd.common.file.virtualfs.VirtualFileSystemFactory @@ -99,9 +99,6 @@ ) object SshServerConfiguration { - // The default configuration key under which to lookup the ssh server configuration. - final val parentKey: ConfigKey = ConfigKey("ssh") - // A key marker for storing the owner id of an ssh-key in the ssh server session. final val SshKeyOwnerIdAttribute: AttributeKey[UserId] = new AttributeKey[UserId]() diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelForm.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelForm.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelForm.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelForm.scala 2025-01-31 10:46:28.178967320 +0000 @@ -0,0 +1,113 @@ +/* + * 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.tickets + +import cats._ +import cats.data._ +import cats.syntax.all._ +import de.smederee.tickets.forms.FormValidator +import de.smederee.tickets.forms.types._ + +/** Data container to edit a label. + * + * @param id + * An optional attribute containing the unique internal database ID for the label. + * @param name + * A short descriptive name for the label which is supposed to be unique in a project context. + * @param description + * An optional description if needed. + * @param colour + * A hexadecimal HTML colour code which can be used to mark the label on a rendered website. + */ +final case class LabelForm( + id: Option[LabelId], + name: LabelName, + description: Option[LabelDescription], + colour: ColourCode +) + +object LabelForm extends FormValidator[LabelForm] { + val fieldColour: FormField = FormField("colour") + val fieldDescription: FormField = FormField("description") + val fieldId: FormField = FormField("id") + val fieldName: FormField = FormField("name") + + /** Create a form for editing a label from the given label data. + * + * @param label + * The label which provides the data for the edit form. + * @return + * A label form filled with the data from the given label. + */ + def fromLabel(label: Label): LabelForm = + LabelForm(id = label.id, name = label.name, description = label.description, colour = label.colour) + + override def validate(data: Map[String, String]): ValidatedNec[FormErrors, LabelForm] = { + val id = data + .get(fieldId) + .fold(Option.empty[LabelId].validNec)(s => + LabelId.fromString(s).fold(FormFieldError("Invalid label id!").invalidNec)(id => Option(id).validNec) + ) + .leftMap(es => NonEmptyChain.of(Map(fieldId -> es.toList))) + val name = data + .get(fieldName) + .map(_.trim) // We strip leading and trailing whitespace! + .fold(FormFieldError("No label name given!").invalidNec)(s => + LabelName.from(s).fold(FormFieldError("Invalid label name!").invalidNec)(_.validNec) + ) + .leftMap(es => NonEmptyChain.of(Map(fieldName -> es.toList))) + val description = data + .get(fieldDescription) + .fold(Option.empty[LabelDescription].validNec) { s => + if (s.trim.isEmpty) + Option.empty[LabelDescription].validNec // Sometimes "empty" strings are sent. + else + LabelDescription + .from(s) + .fold(FormFieldError("Invalid label description!").invalidNec)(descr => Option(descr).validNec) + } + .leftMap(es => NonEmptyChain.of(Map(fieldDescription -> es.toList))) + val colour = data + .get(fieldColour) + .fold(FormFieldError("No label colour given!").invalidNec)(s => + ColourCode.from(s).fold(FormFieldError("Invalid label colour!").invalidNec)(_.validNec) + ) + .leftMap(es => NonEmptyChain.of(Map(fieldColour -> es.toList))) + (id, name, description, colour).mapN { case (id, name, description, colour) => + LabelForm(id, name, description, colour) + } + } + + extension (form: LabelForm) { + + /** Convert the form class into a stringified map which is used as underlying data type for form handling in the + * twirl templating library. + * + * @return + * A stringified map containing the data of the form. + */ + def toMap: Map[String, String] = + Map( + LabelForm.fieldId.toString -> form.id.map(_.toString).getOrElse(""), + LabelForm.fieldName.toString -> form.name.toString, + LabelForm.fieldDescription.toString -> form.description.map(_.toString).getOrElse(""), + LabelForm.fieldColour.toString -> form.colour.toString + ) + } + +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala 2025-01-31 10:46:28.178967320 +0000 @@ -0,0 +1,453 @@ +/* + * 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.tickets + +import cats._ +import cats.data._ +import cats.effect._ +import cats.syntax.all._ +import de.smederee.html.LinkTools._ +import de.smederee.html._ +import de.smederee.hub.RequestHelpers.instances.given +import de.smederee.hub.Account +import de.smederee.security.{ CsrfToken, UserId, Username } +import de.smederee.tickets.Project +import de.smederee.tickets.config._ +import de.smederee.tickets.forms.types._ +import org.http4s._ +import org.http4s.dsl.Http4sDsl +import org.http4s.dsl.impl._ +import org.http4s.headers.Location +import org.http4s.implicits._ +import org.http4s.twirl.TwirlInstances._ +import org.slf4j.LoggerFactory + +/** Routes for managing labels (basically CRUD functionality). + * + * @param configuration + * The ticket service configuration. + * @param labelRepo + * A repository for handling database operations for labels. + * @param projectRepo + * A repository for handling database operations regarding our vcs repositories and their metadata. + * @tparam F + * A higher kinded type providing needed functionality, which is usually an IO monad like Async or Sync. + */ +final class LabelRoutes[F[_]: Async]( + configuration: SmedereeTicketsConfiguration, + labelRepo: LabelRepository[F], + projectRepo: ProjectRepository[F] +) extends Http4sDsl[F] { + private val log = LoggerFactory.getLogger(getClass) + + given CsrfProtectionConfiguration = configuration.csrfProtection + + val linkConfig = configuration.externalUrl + + /** Logic for rendering a list of all labels for a project and optionally management functionality. + * + * @param csrf + * An optional CSRF-Token that shall be used. + * @param user + * An optional user account for whom the list of labels shall be rendered. + * @param projectOwnerName + * The username of the account who owns the project. + * @param projectName + * The name of the project. + * @return + * An HTTP response containing the rendered HTML. + */ + private def doShowLabels( + csrf: Option[CsrfToken] + )(user: Option[Account])(projectOwnerName: Username)(projectName: ProjectName): F[Response[F]] = + for { + projectAndId <- loadProject(user)(projectOwnerName, projectName) + resp <- projectAndId match { + case Some((repo, repoId)) => + for { + labels <- labelRepo.allLabels(repoId).compile.toList + projectBaseUri <- Sync[F].delay( + linkConfig.createFullUri( + Uri(path = + Uri.Path( + Vector(Uri.Path.Segment(s"~$projectOwnerName"), Uri.Path.Segment(projectName.toString)) + ) + ) + ) + ) + resp <- Ok( + views.html.editLabels()( + projectBaseUri.addSegment("labels"), + csrf, + labels, + projectBaseUri, + "Manage your project labels.".some, + user, + repo + )() + ) + } yield resp + case _ => NotFound() + } + } yield resp + + /** Load the project metadata with the given owner and name from the database and return it and its primary key id if + * the project exists and is readable by the given user account. + * + * @param currentUser + * The user account that is requesting access to the project or None for a guest user. + * @param projectOwnerName + * The name of the account that owns the project. + * @param projectName + * The name of the project. A project name must start with a letter or number and must contain only alphanumeric + * ASCII characters as well as minus or underscore signs. It must be between 2 and 64 characters long. + * @return + * An option to a tuple holding the [[Project]] and its primary key id. + */ + private def loadProject( + currentUser: Option[Account] + )(projectOwnerName: ProjectOwnerName, projectName: ProjectName): F[Option[(Project, Long)]] = + for { + owner <- projectRepo.findProjectOwner(projectOwnerName) + loadedRepo <- owner match { + case None => Sync[F].pure(None) + case Some(owner) => + ( + projectRepo.findProject(owner, projectName), + projectRepo.findProjectId(owner, projectName) + ).mapN { + case (Some(repo), Some(repoId)) => (repo, repoId).some + case _ => None + } + } + // TODO Replace with whatever we implement as proper permission model. ;-) + projectAndId = currentUser match { + case None => loadedRepo.filter(tuple => tuple._1.isPrivate === false) + case Some(user) => + loadedRepo.filter(tuple => tuple._1.isPrivate === false || tuple._1.owner === user.toProjectOwner) + } + } yield projectAndId + + private val addLabel: AuthedRoutes[Account, F] = AuthedRoutes.of { + case ar @ POST -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter( + projectName + ) / "labels" as user => + ar.req.decodeStrict[F, UrlForm] { urlForm => + for { + csrf <- Sync[F].delay(ar.req.getCsrfToken) + projectAndId <- loadProject(user.some)(projectOwnerName, projectName) + resp <- projectAndId match { + case Some(repo, repoId) => + for { + _ <- Sync[F].raiseUnless(repo.owner.uid === user.uid)(new Error("Only maintainers may add labels!")) + formData <- Sync[F].delay { + urlForm.values.map { t => + val (key, values) = t + ( + key, + values.headOption.getOrElse("") + ) // Pick the first value (a field might get submitted multiple times)! + } + } + form <- Sync[F].delay(LabelForm.validate(formData)) + labels <- projectAndId.traverse(tuple => labelRepo.allLabels(tuple._2).compile.toList) + projectBaseUri <- Sync[F].delay( + linkConfig.createFullUri( + Uri(path = + Uri.Path( + Vector(Uri.Path.Segment(s"~$projectOwnerName"), Uri.Path.Segment(projectName.toString)) + ) + ) + ) + ) + resp <- form match { + case Validated.Invalid(errors) => + BadRequest( + views.html.editLabels()( + projectBaseUri.addSegment("labels"), + csrf, + labels.getOrElse(List.empty), + projectBaseUri, + "Manage your project labels.".some, + user.some, + repo + )(formData, FormErrors.fromNec(errors)) + ) + case Validated.Valid(labelData) => + val label = Label(None, labelData.name, labelData.description, labelData.colour) + for { + checkDuplicate <- labelRepo.findLabel(repoId)(labelData.name) + resp <- checkDuplicate match { + case None => + labelRepo.createLabel(repoId)(label) *> SeeOther( + Location(projectBaseUri.addSegment("labels")) + ) + case Some(_) => + BadRequest( + views.html.editLabels()( + projectBaseUri.addSegment("labels"), + csrf, + labels.getOrElse(List.empty), + projectBaseUri, + "Manage your project labels.".some, + user.some, + repo + )( + formData, + Map( + LabelForm.fieldName -> List(FormFieldError("A label with that name already exists!")) + ) + ) + ) + } + } yield resp + } + } yield resp + case _ => NotFound() + } + } yield resp + } + } + + private val deleteLabel: AuthedRoutes[Account, F] = AuthedRoutes.of { + case ar @ POST -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter( + projectName + ) / "label" / LabelNamePathParameter(labelName) / "delete" as user => + ar.req.decodeStrict[F, UrlForm] { urlForm => + for { + csrf <- Sync[F].delay(ar.req.getCsrfToken) + projectAndId <- loadProject(user.some)(projectOwnerName, projectName) + resp <- projectAndId match { + case Some(repo, repoId) => + for { + _ <- Sync[F].raiseUnless(repo.owner.uid === user.uid)(new Error("Only maintainers may add labels!")) + label <- labelRepo.findLabel(repoId)(labelName) + resp <- label match { + case Some(label) => + for { + formData <- Sync[F].delay { + urlForm.values.map { t => + val (key, values) = t + ( + key, + values.headOption.getOrElse("") + ) // Pick the first value (a field might get submitted multiple times)! + } + } + projectBaseUri <- Sync[F].delay( + linkConfig.createFullUri( + Uri(path = + Uri.Path( + Vector( + Uri.Path.Segment(s"~$projectOwnerName"), + Uri.Path.Segment(projectName.toString) + ) + ) + ) + ) + ) + userIsSure <- Sync[F].delay(formData.get("i-am-sure").exists(_ === "yes")) + labelIdMatches <- Sync[F].delay( + formData + .get(LabelForm.fieldId) + .flatMap(LabelId.fromString) + .exists(id => label.id.exists(_ === id)) + ) + labelNameMatches <- Sync[F].delay( + formData.get(LabelForm.fieldName).flatMap(LabelName.from).exists(_ === labelName) + ) + resp <- (labelIdMatches && labelNameMatches && userIsSure) match { + case false => BadRequest("Invalid form data!") + case true => + labelRepo.deleteLabel(label) *> SeeOther( + Location(projectBaseUri.addSegment("labels")) + ) + } + } yield resp + case _ => NotFound("Label not found!") + } + } yield resp + case _ => NotFound("Repository not found!") + } + } yield resp + } + } + + private val editLabel: AuthedRoutes[Account, F] = AuthedRoutes.of { + case ar @ POST -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter( + projectName + ) / "label" / LabelNamePathParameter(labelName) as user => + ar.req.decodeStrict[F, UrlForm] { urlForm => + for { + csrf <- Sync[F].delay(ar.req.getCsrfToken) + projectAndId <- loadProject(user.some)(projectOwnerName, projectName) + label <- projectAndId match { + case Some((_, repoId)) => labelRepo.findLabel(repoId)(labelName) + case _ => Sync[F].delay(None) + } + resp <- (projectAndId, label) match { + case (Some(repo, repoId), Some(label)) => + for { + _ <- Sync[F].raiseUnless(repo.owner.uid === user.uid)(new Error("Only maintainers may add labels!")) + projectBaseUri <- Sync[F].delay( + linkConfig.createFullUri( + Uri(path = + Uri.Path( + Vector(Uri.Path.Segment(s"~$projectOwnerName"), Uri.Path.Segment(projectName.toString)) + ) + ) + ) + ) + actionUri <- Sync[F].delay(projectBaseUri.addSegment("label").addSegment(label.name.toString)) + formData <- Sync[F].delay { + urlForm.values.map { t => + val (key, values) = t + ( + key, + values.headOption.getOrElse("") + ) // Pick the first value (a field might get submitted multiple times)! + } + } + labelIdMatches <- Sync[F].delay( + formData + .get(LabelForm.fieldId) + .flatMap(LabelId.fromString) + .exists(id => label.id.exists(_ === id)) match { + case false => + NonEmptyChain + .of(Map(LabelForm.fieldGlobal -> List(FormFieldError("Label ID does not match!")))) + .invalidNec + case true => label.id.validNec + } + ) + form <- Sync[F].delay(LabelForm.validate(formData)) + resp <- form match { + case Validated.Invalid(errors) => + BadRequest( + views.html.editLabel()( + actionUri, + csrf, + label, + projectBaseUri, + s"Edit label ${label.name}".some, + user, + repo + )( + formData.toMap, + FormErrors.fromNec(errors) + ) + ) + case Validated.Valid(labelData) => + val updatedLabel = + label.copy(name = labelData.name, description = labelData.description, colour = labelData.colour) + for { + checkDuplicate <- labelRepo.findLabel(repoId)(updatedLabel.name) + resp <- checkDuplicate.filterNot(_.id === updatedLabel.id) match { + case None => + labelRepo.updateLabel(updatedLabel) *> SeeOther( + Location(projectBaseUri.addSegment("labels")) + ) + case Some(_) => + BadRequest( + views.html.editLabel()( + actionUri, + csrf, + label, + projectBaseUri, + s"Edit label ${label.name}".some, + user, + repo + )( + formData, + Map( + LabelForm.fieldName -> List(FormFieldError("A label with that name already exists!")) + ) + ) + ) + } + } yield resp + } + } yield resp + case _ => NotFound() + } + } yield resp + } + } + + private val showEditLabelForm: AuthedRoutes[Account, F] = AuthedRoutes.of { + case ar @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter( + projectName + ) / "label" / LabelNamePathParameter(labelName) / "edit" as user => + for { + csrf <- Sync[F].delay(ar.req.getCsrfToken) + projectAndId <- loadProject(user.some)(projectOwnerName, projectName) + label <- projectAndId match { + case Some((_, repoId)) => labelRepo.findLabel(repoId)(labelName) + case _ => Sync[F].delay(None) + } + resp <- (projectAndId, label) match { + case (Some(repo, repoId), Some(label)) => + for { + projectBaseUri <- Sync[F].delay( + linkConfig.createFullUri( + Uri(path = + Uri.Path( + Vector(Uri.Path.Segment(s"~$projectOwnerName"), Uri.Path.Segment(projectName.toString)) + ) + ) + ) + ) + actionUri <- Sync[F].delay(projectBaseUri.addSegment("label").addSegment(label.name.toString)) + formData <- Sync[F].delay(LabelForm.fromLabel(label)) + resp <- Ok( + views.html + .editLabel()(actionUri, csrf, label, projectBaseUri, s"Edit label ${label.name}".some, user, repo)( + formData.toMap + ) + ) + } yield resp + case _ => NotFound() + } + } yield resp + } + + private val showEditLabelsPage: AuthedRoutes[Account, F] = AuthedRoutes.of { + case ar @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter( + projectName + ) / "labels" as user => + for { + csrf <- Sync[F].delay(ar.req.getCsrfToken) + resp <- doShowLabels(csrf)(user.some)(projectOwnerName)(projectName) + } yield resp + } + + private val showLabelsForGuests: HttpRoutes[F] = HttpRoutes.of { + case req @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter( + projectName + ) / "labels" => + for { + csrf <- Sync[F].delay(req.getCsrfToken) + resp <- doShowLabels(csrf)(None)(projectOwnerName)(projectName) + } yield resp + } + + val protectedRoutes = addLabel <+> deleteLabel <+> editLabel <+> showEditLabelForm <+> showEditLabelsPage + + val routes = showLabelsForGuests + +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneForm.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneForm.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneForm.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneForm.scala 2025-01-31 10:46:28.178967320 +0000 @@ -0,0 +1,126 @@ +/* + * 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.tickets + +import java.time._ + +import cats._ +import cats.data._ +import cats.syntax.all._ +import de.smederee.tickets.forms.FormValidator +import de.smederee.tickets.forms.types._ + +/** Data container to edit a milestone. + * + * @param id + * An optional attribute containing the unique internal database ID for the milestone. + * @param title + * A title for the milestone, usually a version number, a word or a short phrase that is supposed to be unique within + * a project context. + * @param description + * An optional longer description of the milestone. + * @param dueDate + * An optional date on which the milestone is supposed to be reached. + */ +final case class MilestoneForm( + id: Option[MilestoneId], + title: MilestoneTitle, + description: Option[MilestoneDescription], + dueDate: Option[LocalDate] +) + +object MilestoneForm extends FormValidator[MilestoneForm] { + val fieldDescription: FormField = FormField("description") + val fieldDueDate: FormField = FormField("due_date") + val fieldId: FormField = FormField("id") + val fieldTitle: FormField = FormField("title") + + /** Create a form for editing a milestone from the given milestone data. + * + * @param milestone + * The milestone which provides the data for the edit form. + * @return + * A milestone form filled with the data from the given milestone. + */ + def fromMilestone(milestone: Milestone): MilestoneForm = + MilestoneForm( + id = milestone.id, + title = milestone.title, + description = milestone.description, + dueDate = milestone.dueDate + ) + + override def validate(data: Map[String, String]): ValidatedNec[FormErrors, MilestoneForm] = { + val id = data + .get(fieldId) + .fold(Option.empty[MilestoneId].validNec)(s => + MilestoneId.fromString(s).fold(FormFieldError("Invalid milestone id!").invalidNec)(id => Option(id).validNec) + ) + .leftMap(es => NonEmptyChain.of(Map(fieldId -> es.toList))) + val title = data + .get(fieldTitle) + .map(_.trim) // We strip leading and trailing whitespace! + .fold(FormFieldError("No milestone title given!").invalidNec)(s => + MilestoneTitle.from(s).fold(FormFieldError("Invalid milestone title!").invalidNec)(_.validNec) + ) + .leftMap(es => NonEmptyChain.of(Map(fieldTitle -> es.toList))) + val description = data + .get(fieldDescription) + .fold(Option.empty[MilestoneDescription].validNec) { s => + if (s.trim.isEmpty) + Option.empty[MilestoneDescription].validNec // Sometimes "empty" strings are sent. + else + MilestoneDescription + .from(s) + .fold(FormFieldError("Invalid milestone description!").invalidNec)(descr => Option(descr).validNec) + } + .leftMap(es => NonEmptyChain.of(Map(fieldDescription -> es.toList))) + val dueDate = data + .get(fieldDueDate) + .fold(Option.empty[LocalDate].validNec) { s => + if (s.trim.isEmpty) + Option.empty[LocalDate].validNec + else + Validated + .catchNonFatal(LocalDate.parse(s)) + .map(date => Option(date)) + } + .leftMap(_ => NonEmptyChain.of(Map(fieldDueDate -> List(FormFieldError("Invalid milestone due date!"))))) + (id, title, description, dueDate).mapN { case (id, title, description, dueDate) => + MilestoneForm(id, title, description, dueDate) + } + } + + extension (form: MilestoneForm) { + + /** Convert the form class into a stringified map which is used as underlying data type for form handling in the + * twirl templating library. + * + * @return + * A stringified map containing the data of the form. + */ + def toMap: Map[String, String] = + Map( + MilestoneForm.fieldId.toString -> form.id.map(_.toString).getOrElse(""), + MilestoneForm.fieldTitle.toString -> form.title.toString, + MilestoneForm.fieldDescription.toString -> form.description.map(_.toString).getOrElse(""), + MilestoneForm.fieldDueDate.toString -> form.dueDate.map(_.toString).getOrElse("") + ) + } + +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala 2025-01-31 10:46:28.178967320 +0000 @@ -0,0 +1,472 @@ +/* + * 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.tickets + +import cats._ +import cats.data._ +import cats.effect._ +import cats.syntax.all._ +import de.smederee.html.LinkTools._ +import de.smederee.html._ +import de.smederee.hub.Account +import de.smederee.hub.RequestHelpers.instances.given +import de.smederee.security.{ CsrfToken, UserId, Username } +import de.smederee.tickets.Project +import de.smederee.tickets.config._ +import de.smederee.tickets.forms.types._ +import org.http4s._ +import org.http4s.dsl.Http4sDsl +import org.http4s.dsl.impl._ +import org.http4s.headers.Location +import org.http4s.implicits._ +import org.http4s.twirl.TwirlInstances._ +import org.slf4j.LoggerFactory + +/** Routes for managing milestones (basically CRUD functionality). + * + * @param configuration + * The ticket service configuration. + * @param milestoneRepo + * A repository for handling database operations for milestones. + * @param projectRepo + * A repository for handling database operations regarding our vcs repositories and their metadata. + * @tparam F + * A higher kinded type providing needed functionality, which is usually an IO monad like Async or Sync. + */ +final class MilestoneRoutes[F[_]: Async]( + configuration: SmedereeTicketsConfiguration, + milestoneRepo: MilestoneRepository[F], + projectRepo: ProjectRepository[F] +) extends Http4sDsl[F] { + private val log = LoggerFactory.getLogger(getClass) + + given CsrfProtectionConfiguration = configuration.csrfProtection + + val linkConfig = configuration.externalUrl + + /** Logic for rendering a list of all milestones for a repository and optionally management functionality. + * + * @param csrf + * An optional CSRF-Token that shall be used. + * @param user + * An optional user account for whom the list of milestones shall be rendered. + * @param repositoryOwnerName + * The username of the account who owns the repository. + * @param repositoryName + * The name of the repository. + * @return + * An HTTP response containing the rendered HTML. + */ + private def doShowMilestones( + csrf: Option[CsrfToken] + )(user: Option[Account])(repositoryOwnerName: Username)(repositoryName: ProjectName): F[Response[F]] = + for { + repoAndId <- loadRepo(user)(repositoryOwnerName, repositoryName) + resp <- repoAndId match { + case Some((repo, repoId)) => + for { + milestones <- milestoneRepo.allMilestones(repoId).compile.toList + repositoryBaseUri <- Sync[F].delay( + linkConfig.createFullUri( + Uri(path = + Uri.Path( + Vector(Uri.Path.Segment(s"~$repositoryOwnerName"), Uri.Path.Segment(repositoryName.toString)) + ) + ) + ) + ) + resp <- Ok( + views.html.editMilestones()( + repositoryBaseUri.addSegment("milestones"), + csrf, + milestones, + repositoryBaseUri, + "Manage your repository milestones.".some, + user, + repo + )() + ) + } yield resp + case _ => NotFound() + } + } yield resp + + /** Load the repository metadata with the given owner and name from the database and return it and its primary key id + * if the repository exists and is readable by the given user account. + * + * @param currentUser + * The user account that is requesting access to the repository or None for a guest user. + * @param repositoryOwnerName + * The name of the account that owns the repository. + * @param repositoryName + * The name of the repository. A repository name must start with a letter or number and must contain only + * alphanumeric ASCII characters as well as minus or underscore signs. It must be between 2 and 64 characters long. + * @return + * An option to a tuple holding the [[Project]] and its primary key id. + */ + private def loadRepo( + currentUser: Option[Account] + )(repositoryOwnerName: Username, repositoryName: ProjectName): F[Option[(Project, Long)]] = + for { + owner <- projectRepo.findProjectOwner(repositoryOwnerName) + loadedRepo <- owner match { + case None => Sync[F].pure(None) + case Some(owner) => + ( + projectRepo.findProject(owner, repositoryName), + projectRepo.findProjectId(owner, repositoryName) + ).mapN { + case (Some(repo), Some(repoId)) => (repo, repoId).some + case _ => None + } + } + // TODO Replace with whatever we implement as proper permission model. ;-) + repoAndId = currentUser match { + case None => loadedRepo.filter(tuple => tuple._1.isPrivate === false) + case Some(user) => + loadedRepo.filter(tuple => tuple._1.isPrivate === false || tuple._1.owner === user.toProjectOwner) + } + } yield repoAndId + + private val addMilestone: AuthedRoutes[Account, F] = AuthedRoutes.of { + case ar @ POST -> Root / UsernamePathParameter(repositoryOwnerName) / ProjectNamePathParameter( + repositoryName + ) / "milestones" as user => + ar.req.decodeStrict[F, UrlForm] { urlForm => + for { + csrf <- Sync[F].delay(ar.req.getCsrfToken) + repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName) + resp <- repoAndId match { + case Some(repo, repoId) => + for { + _ <- Sync[F].raiseUnless(repo.owner.uid === user.uid)(new Error("Only maintainers may add milestones!")) + formData <- Sync[F].delay { + urlForm.values.map { t => + val (key, values) = t + ( + key, + values.headOption.getOrElse("") + ) // Pick the first value (a field might get submitted multiple times)! + } + } + form <- Sync[F].delay(MilestoneForm.validate(formData)) + milestones <- repoAndId.traverse(tuple => milestoneRepo.allMilestones(tuple._2).compile.toList) + repositoryBaseUri <- Sync[F].delay( + linkConfig.createFullUri( + Uri(path = + Uri.Path( + Vector(Uri.Path.Segment(s"~$repositoryOwnerName"), Uri.Path.Segment(repositoryName.toString)) + ) + ) + ) + ) + resp <- form match { + case Validated.Invalid(errors) => + BadRequest( + views.html.editMilestones()( + repositoryBaseUri.addSegment("milestones"), + csrf, + milestones.getOrElse(List.empty), + repositoryBaseUri, + "Manage your repository milestones.".some, + user.some, + repo + )(formData, FormErrors.fromNec(errors)) + ) + case Validated.Valid(milestoneData) => + val milestone = + Milestone(None, milestoneData.title, milestoneData.description, milestoneData.dueDate) + for { + checkDuplicate <- milestoneRepo.findMilestone(repoId)(milestoneData.title) + resp <- checkDuplicate match { + case None => + milestoneRepo.createMilestone(repoId)(milestone) *> SeeOther( + Location(repositoryBaseUri.addSegment("milestones")) + ) + case Some(_) => + BadRequest( + views.html.editMilestones()( + repositoryBaseUri.addSegment("milestones"), + csrf, + milestones.getOrElse(List.empty), + repositoryBaseUri, + "Manage your repository milestones.".some, + user.some, + repo + )( + formData, + Map( + MilestoneForm.fieldTitle -> List( + FormFieldError("A milestone with that name already exists!") + ) + ) + ) + ) + } + } yield resp + } + } yield resp + case _ => NotFound() + } + } yield resp + } + } + + private val deleteMilestone: AuthedRoutes[Account, F] = AuthedRoutes.of { + case ar @ POST -> Root / UsernamePathParameter(repositoryOwnerName) / ProjectNamePathParameter( + repositoryName + ) / "milestone" / MilestoneTitlePathParameter(milestoneTitle) / "delete" as user => + ar.req.decodeStrict[F, UrlForm] { urlForm => + for { + csrf <- Sync[F].delay(ar.req.getCsrfToken) + repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName) + resp <- repoAndId match { + case Some(repo, repoId) => + for { + _ <- Sync[F].raiseUnless(repo.owner.uid === user.uid)(new Error("Only maintainers may add milestones!")) + milestone <- milestoneRepo.findMilestone(repoId)(milestoneTitle) + resp <- milestone match { + case Some(milestone) => + for { + formData <- Sync[F].delay { + urlForm.values.map { t => + val (key, values) = t + ( + key, + values.headOption.getOrElse("") + ) // Pick the first value (a field might get submitted multiple times)! + } + } + repositoryBaseUri <- Sync[F].delay( + linkConfig.createFullUri( + Uri(path = + Uri.Path( + Vector( + Uri.Path.Segment(s"~$repositoryOwnerName"), + Uri.Path.Segment(repositoryName.toString) + ) + ) + ) + ) + ) + userIsSure <- Sync[F].delay(formData.get("i-am-sure").exists(_ === "yes")) + milestoneIdMatches <- Sync[F].delay( + formData + .get(MilestoneForm.fieldId) + .flatMap(MilestoneId.fromString) + .exists(id => milestone.id.exists(_ === id)) + ) + milestoneTitleMatches <- Sync[F].delay( + formData.get(MilestoneForm.fieldTitle).flatMap(MilestoneTitle.from).exists(_ === milestoneTitle) + ) + resp <- (milestoneIdMatches && milestoneTitleMatches && userIsSure) match { + case false => BadRequest("Invalid form data!") + case true => + milestoneRepo.deleteMilestone(milestone) *> SeeOther( + Location(repositoryBaseUri.addSegment("milestones")) + ) + } + } yield resp + case _ => NotFound("Milestone not found!") + } + } yield resp + case _ => NotFound("Repository not found!") + } + } yield resp + } + } + + private val editMilestone: AuthedRoutes[Account, F] = AuthedRoutes.of { + case ar @ POST -> Root / UsernamePathParameter(repositoryOwnerName) / ProjectNamePathParameter( + repositoryName + ) / "milestone" / MilestoneTitlePathParameter(milestoneTitle) as user => + ar.req.decodeStrict[F, UrlForm] { urlForm => + for { + csrf <- Sync[F].delay(ar.req.getCsrfToken) + repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName) + milestone <- repoAndId match { + case Some((_, repoId)) => milestoneRepo.findMilestone(repoId)(milestoneTitle) + case _ => Sync[F].delay(None) + } + resp <- (repoAndId, milestone) match { + case (Some(repo, repoId), Some(milestone)) => + for { + _ <- Sync[F].raiseUnless(repo.owner.uid === user.uid)(new Error("Only maintainers may add milestones!")) + repositoryBaseUri <- Sync[F].delay( + linkConfig.createFullUri( + Uri(path = + Uri.Path( + Vector(Uri.Path.Segment(s"~$repositoryOwnerName"), Uri.Path.Segment(repositoryName.toString)) + ) + ) + ) + ) + actionUri <- Sync[F].delay( + repositoryBaseUri.addSegment("milestone").addSegment(milestone.title.toString) + ) + formData <- Sync[F].delay { + urlForm.values.map { t => + val (key, values) = t + ( + key, + values.headOption.getOrElse("") + ) // Pick the first value (a field might get submitted multiple times)! + } + } + milestoneIdMatches <- Sync[F].delay( + formData + .get(MilestoneForm.fieldId) + .flatMap(MilestoneId.fromString) + .exists(id => milestone.id.exists(_ === id)) match { + case false => + NonEmptyChain + .of(Map(MilestoneForm.fieldGlobal -> List(FormFieldError("Milestone ID does not match!")))) + .invalidNec + case true => milestone.id.validNec + } + ) + form <- Sync[F].delay(MilestoneForm.validate(formData)) + resp <- form match { + case Validated.Invalid(errors) => + BadRequest( + views.html.editMilestone()( + actionUri, + csrf, + milestone, + repositoryBaseUri, + s"Edit milestone ${milestone.title}".some, + user, + repo + )( + formData.toMap, + FormErrors.fromNec(errors) + ) + ) + case Validated.Valid(milestoneData) => + val updatedMilestone = + milestone.copy( + title = milestoneData.title, + description = milestoneData.description, + dueDate = milestoneData.dueDate + ) + for { + checkDuplicate <- milestoneRepo.findMilestone(repoId)(updatedMilestone.title) + resp <- checkDuplicate.filterNot(_.id === updatedMilestone.id) match { + case None => + milestoneRepo.updateMilestone(updatedMilestone) *> SeeOther( + Location(repositoryBaseUri.addSegment("milestones")) + ) + case Some(_) => + BadRequest( + views.html.editMilestone()( + actionUri, + csrf, + milestone, + repositoryBaseUri, + s"Edit milestone ${milestone.title}".some, + user, + repo + )( + formData, + Map( + MilestoneForm.fieldTitle -> List( + FormFieldError("A milestone with that name already exists!") + ) + ) + ) + ) + } + } yield resp + } + } yield resp + case _ => NotFound() + } + } yield resp + } + } + + private val showEditMilestoneForm: AuthedRoutes[Account, F] = AuthedRoutes.of { + case ar @ GET -> Root / UsernamePathParameter(repositoryOwnerName) / ProjectNamePathParameter( + repositoryName + ) / "milestone" / MilestoneTitlePathParameter(milestoneTitle) / "edit" as user => + for { + csrf <- Sync[F].delay(ar.req.getCsrfToken) + repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName) + milestone <- repoAndId match { + case Some((_, repoId)) => milestoneRepo.findMilestone(repoId)(milestoneTitle) + case _ => Sync[F].delay(None) + } + resp <- (repoAndId, milestone) match { + case (Some(repo, repoId), Some(milestone)) => + for { + repositoryBaseUri <- Sync[F].delay( + linkConfig.createFullUri( + Uri(path = + Uri.Path( + Vector(Uri.Path.Segment(s"~$repositoryOwnerName"), Uri.Path.Segment(repositoryName.toString)) + ) + ) + ) + ) + actionUri <- Sync[F].delay(repositoryBaseUri.addSegment("milestone").addSegment(milestone.title.toString)) + formData <- Sync[F].delay(MilestoneForm.fromMilestone(milestone)) + resp <- Ok( + views.html.editMilestone()( + actionUri, + csrf, + milestone, + repositoryBaseUri, + s"Edit milestone ${milestone.title}".some, + user, + repo + )( + formData.toMap + ) + ) + } yield resp + case _ => NotFound() + } + } yield resp + } + + private val showEditMilestonesPage: AuthedRoutes[Account, F] = AuthedRoutes.of { + case ar @ GET -> Root / UsernamePathParameter(repositoryOwnerName) / ProjectNamePathParameter( + repositoryName + ) / "milestones" as user => + for { + csrf <- Sync[F].delay(ar.req.getCsrfToken) + resp <- doShowMilestones(csrf)(user.some)(repositoryOwnerName)(repositoryName) + } yield resp + } + + private val showMilestonesForGuests: HttpRoutes[F] = HttpRoutes.of { + case req @ GET -> Root / UsernamePathParameter(repositoryOwnerName) / ProjectNamePathParameter( + repositoryName + ) / "milestones" => + for { + csrf <- Sync[F].delay(req.getCsrfToken) + resp <- doShowMilestones(csrf)(None)(repositoryOwnerName)(repositoryName) + } yield resp + } + + val protectedRoutes = + addMilestone <+> deleteMilestone <+> editMilestone <+> showEditMilestoneForm <+> showEditMilestonesPage + + val routes = showMilestonesForGuests + +} diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/UsernamePathParameter.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/UsernamePathParameter.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/UsernamePathParameter.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/UsernamePathParameter.scala 2025-01-31 10:46:28.178967320 +0000 @@ -0,0 +1,32 @@ +/* + * 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.tickets + +import de.smederee.security.Username + +/** Extractor to retrieve an Username from a path parameter. + */ +object UsernamePathParameter { + def unapply(str: String): Option[Username] = + Option(str).flatMap { string => + if (string.startsWith("~")) + Username.from(string.drop(1)) + else + None + } +} diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html 2025-01-31 10:46:28.178967320 +0000 @@ -1,3 +1,6 @@ +@import de.smederee.hub._ +@import de.smederee.hub.views.html._ + @(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account)(actionBaseUri: Uri, deleteAction: Uri, validateAction: Uri) @main(baseUri, lang)()(csrf, title, user.some) { @defining(lang.toLocale) { implicit locale => diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/sshSettings.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/sshSettings.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/sshSettings.scala.html 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/sshSettings.scala.html 2025-01-31 10:46:28.178967320 +0000 @@ -1,5 +1,9 @@ @import de.smederee.hub.AddPublicSshKeyForm._ +@import de.smederee.hub._ +@import de.smederee.hub.forms.types._ @import de.smederee.ssh.PublicSshKey +@import de.smederee.hub.views.html._ +@import de.smederee.hub.views.html.forms.renderFormErrors @(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account)(actionBaseUri: Uri, addAction: Uri, deleteAction: Uri, keys: List[PublicSshKey])(formData: Map[String, String] = Map.empty, formErrors: FormErrors = FormErrors.empty) @main(baseUri, lang)()(csrf, title, user.some) { diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/contact.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/contact.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/contact.scala.html 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/contact.scala.html 2025-01-31 10:46:28.178967320 +0000 @@ -1,3 +1,5 @@ +@import de.smederee.hub._ + @(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en") )(csrf: Option[CsrfToken] = None, diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/createRepository.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/createRepository.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/createRepository.scala.html 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/createRepository.scala.html 2025-01-31 10:46:28.178967320 +0000 @@ -1,4 +1,7 @@ +@import de.smederee.hub._ +@import de.smederee.hub.forms.types._ @import NewVcsRepositoryForm._ +@import de.smederee.hub.views.html.forms.renderFormErrors @(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(action: Uri, csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account)(formData: Map[String, String] = Map.empty, formErrors: FormErrors = FormErrors.empty) @main(baseUri, lang)()(csrf, title, user.some) { diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/csrfToken.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/csrfToken.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/csrfToken.scala.html 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/csrfToken.scala.html 2025-01-31 10:46:28.178967320 +0000 @@ -1,2 +1,4 @@ +@import de.smederee.hub.config.Constants + @(csrf: Option[CsrfToken]) <input type="hidden" name="@Constants.csrfCookieName" value="@csrf"> diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/deleteRepository.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/deleteRepository.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/deleteRepository.scala.html 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/deleteRepository.scala.html 2025-01-31 10:46:28.178967320 +0000 @@ -1,3 +1,5 @@ +@import de.smederee.hub._ + @(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en") )(deleteAction: Uri, diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/editRepository.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/editRepository.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/editRepository.scala.html 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/editRepository.scala.html 2025-01-31 10:46:28.178967320 +0000 @@ -1,4 +1,7 @@ +@import de.smederee.hub._ +@import de.smederee.hub.forms.types._ @import EditVcsRepositoryForm._ +@import de.smederee.hub.views.html.forms.renderFormErrors @(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en") diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/emails/validate.scala.txt new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/emails/validate.scala.txt --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/emails/validate.scala.txt 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/emails/validate.scala.txt 2025-01-31 10:46:28.182967326 +0000 @@ -1,3 +1,4 @@ +@import de.smederee.hub._ @(user: Account, validationToken: ValidationToken, validationBaseUri: Uri) Hello @{user.name}, diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/errors/unvalidatedAccount.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/errors/unvalidatedAccount.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/errors/unvalidatedAccount.scala.html 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/errors/unvalidatedAccount.scala.html 2025-01-31 10:46:28.182967326 +0000 @@ -1,3 +1,6 @@ +@import de.smederee.hub._ +@import de.smederee.hub.views.html._ + @(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account) @main(baseUri, lang)()(csrf, title, user.some) { @defining(lang.toLocale) { implicit locale => diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/forms/renderFormErrors.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/forms/renderFormErrors.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/forms/renderFormErrors.scala.html 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/forms/renderFormErrors.scala.html 2025-01-31 10:46:28.182967326 +0000 @@ -1,3 +1,5 @@ +@import de.smederee.hub.forms.types._ + @(field: FormField, errors: FormErrors) @errors.get(field).map { fieldErrors => <div class="alert alert-warning"> diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/imprint.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/imprint.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/imprint.scala.html 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/imprint.scala.html 2025-01-31 10:46:28.178967320 +0000 @@ -1,3 +1,5 @@ +@import de.smederee.hub._ + @(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en") )(csrf: Option[CsrfToken] = None, diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/index.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/index.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/index.scala.html 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/index.scala.html 2025-01-31 10:46:28.178967320 +0000 @@ -1,3 +1,4 @@ +@import de.smederee.hub._ @import SignupForm._ @(baseUri: Uri = Uri(path = Uri.Path.Root), diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/login.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/login.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/login.scala.html 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/login.scala.html 2025-01-31 10:46:28.178967320 +0000 @@ -1,4 +1,7 @@ +@import de.smederee.hub._ +@import de.smederee.hub.forms.types._ @import LoginForm._ +@import de.smederee.hub.views.html.forms.renderFormErrors @(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(action: Uri, csrf: Option[CsrfToken] = None, title: Option[String] = None)(formData: Map[String, String] = Map.empty, formErrors: FormErrors = FormErrors.empty) @main(baseUri, lang)()(csrf, title, user = None) { diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/main.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/main.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/main.scala.html 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/main.scala.html 2025-01-31 10:46:28.178967320 +0000 @@ -1,3 +1,5 @@ +@import de.smederee.hub.Account + @(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"), tags: MetaTags = MetaTags.empty diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html 2025-01-31 10:46:28.178967320 +0000 @@ -1,3 +1,5 @@ +@import de.smederee.hub._ + @(baseUri: Uri, lang: LanguageCode)(csrf: Option[CsrfToken] = None, extraCss: Option[String] = None, user: Option[Account] = None) @defining(lang.toLocale) { implicit locale => <nav class="home-menu pure-menu pure-menu-horizontal pure-menu-scrollable @extraCss"> diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/privacyPolicy.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/privacyPolicy.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/privacyPolicy.scala.html 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/privacyPolicy.scala.html 2025-01-31 10:46:28.178967320 +0000 @@ -1,3 +1,5 @@ +@import de.smederee.hub._ + @(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en") )(csrf: Option[CsrfToken] = None, diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/publicAlpha.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/publicAlpha.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/publicAlpha.scala.html 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/publicAlpha.scala.html 2025-01-31 10:46:28.178967320 +0000 @@ -1,3 +1,5 @@ +@import de.smederee.hub._ + @(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en") )(csrf: Option[CsrfToken] = None, diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/repositoryPatchMetadata.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/repositoryPatchMetadata.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/repositoryPatchMetadata.scala.html 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/repositoryPatchMetadata.scala.html 2025-01-31 10:46:28.178967320 +0000 @@ -1,3 +1,5 @@ +@import de.smederee.hub._ + @(actionBaseUri: Option[Uri], patch: VcsRepositoryPatchMetadata)(implicit locale: java.util.Locale) <div class="patch"> <div class="patch-details"> diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showAllRepositories.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showAllRepositories.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showAllRepositories.scala.html 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showAllRepositories.scala.html 2025-01-31 10:46:28.178967320 +0000 @@ -1,3 +1,5 @@ +@import de.smederee.hub._ + @(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(actionBaseUri: Uri, csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Option[Account])(listing: List[VcsRepository]) @main(baseUri, lang)()(csrf, title, user) { @defining(lang.toLocale) { implicit locale => diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositories.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositories.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositories.scala.html 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositories.scala.html 2025-01-31 10:46:28.178967320 +0000 @@ -1,3 +1,6 @@ +@import de.smederee.hub._ +@import de.smederee.security.Username + @(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(actionBaseUri: Uri, csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Option[Account])(listing: List[VcsRepository], repositoriesOwner: Username) @main(baseUri, lang)()(csrf, title, user) { @defining(lang.toLocale) { implicit locale => 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-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryFiles.scala.html 2025-01-31 10:46:28.178967320 +0000 @@ -1,5 +1,6 @@ @import java.util.Locale @import de.smederee.hub.ToDoTextCssMapping._ +@import de.smederee.hub._ @(baseUri: Uri, lang: LanguageCode = LanguageCode("en") diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryHistory.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryHistory.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryHistory.scala.html 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryHistory.scala.html 2025-01-31 10:46:28.178967320 +0000 @@ -1,3 +1,5 @@ +@import de.smederee.hub._ + @(baseUri: Uri, lang: LanguageCode = LanguageCode("en") )(actionBaseUri: Uri, diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html 2025-01-31 10:46:28.178967320 +0000 @@ -1,3 +1,5 @@ +@import de.smederee.hub._ + @(baseUri: Uri )(activeUri: Option[Uri], repositoryBaseUri: Uri, diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html 2025-01-31 10:46:28.178967320 +0000 @@ -1,3 +1,5 @@ +@import de.smederee.hub._ + @(baseUri: Uri, lang: LanguageCode = LanguageCode("en") )(actionBaseUri: Uri, diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryPatch.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryPatch.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryPatch.scala.html 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryPatch.scala.html 2025-01-31 10:46:28.178967320 +0000 @@ -1,3 +1,5 @@ +@import de.smederee.hub._ + @(baseUri: Uri, lang: LanguageCode = LanguageCode("en") )(actionBaseUri: Uri, diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/signup.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/signup.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/signup.scala.html 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/signup.scala.html 2025-01-31 10:46:28.178967320 +0000 @@ -1,4 +1,7 @@ +@import de.smederee.hub._ +@import de.smederee.hub.forms.types._ @import SignupForm._ +@import de.smederee.hub.views.html.forms.renderFormErrors @(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(action: Uri, csrf: Option[CsrfToken] = None, title: Option[String] = None)(formData: Map[String, String] = Map.empty, formErrors: FormErrors = FormErrors.empty) @main(baseUri, lang)()(csrf, title, user = None) { diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/termsOfUse.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/termsOfUse.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/termsOfUse.scala.html 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/termsOfUse.scala.html 2025-01-31 10:46:28.178967320 +0000 @@ -1,3 +1,5 @@ +@import de.smederee.hub._ + @(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en") )(csrf: Option[CsrfToken] = None, diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/welcome.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/welcome.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/welcome.scala.html 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/welcome.scala.html 2025-01-31 10:46:28.178967320 +0000 @@ -1,3 +1,5 @@ +@import de.smederee.hub._ + @(baseUri: Uri, lang: LanguageCode = LanguageCode("en"))(csrf: Option[CsrfToken] = None, title: Option[String] = None) @main(baseUri, lang)()(csrf, title, user = None) { @defining(lang.toLocale) { implicit locale => diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/csrfToken.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/csrfToken.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/csrfToken.scala.html 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/csrfToken.scala.html 2025-01-31 10:46:28.182967326 +0000 @@ -0,0 +1,4 @@ +@import de.smederee.hub.config.Constants + +@(csrf: Option[CsrfToken]) +<input type="hidden" name="@Constants.csrfCookieName" value="@csrf"> diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editLabel.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editLabel.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editLabel.scala.html 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editLabel.scala.html 2025-01-31 10:46:28.182967326 +0000 @@ -0,0 +1,92 @@ +@import de.smederee.hub.Account +@import de.smederee.hub.views.html.main +@import de.smederee.tickets.LabelForm._ +@import de.smederee.tickets._ +@import de.smederee.tickets.forms._ +@import de.smederee.tickets.forms.types._ +@import de.smederee.tickets.views.html.forms.renderFormErrors +@import de.smederee.tickets.views.html.showProjectMenu + +@(baseUri: Uri = Uri(path = Uri.Path.Root), + lang: LanguageCode = LanguageCode("en") +)(action: Uri, + csrf: Option[CsrfToken] = None, + label: Label, + projectBaseUri: Uri, + title: Option[String] = None, + user: Account, + project: Project +)(formData: Map[String, String] = Map.empty, + formErrors: FormErrors = FormErrors.empty +) +@main(baseUri, lang)()(csrf, title, user.some) { +@defining(lang.toLocale) { implicit locale => +<div class="content"> + <div class="pure-g"> + <div class="pure-u-1"> + <div class="l-box-left-right"> + <h2><a href="@{baseUri.addSegment(s"~${project.owner.name}")}">~@project.owner.name</a>/@project.name</h2> + @showProjectMenu(baseUri)(projectBaseUri.addSegment("labels").some, projectBaseUri, user.some, project) + <div class="project-summary-description"> + @Messages("project.labels.edit.title") + </div> + </div> + </div> + </div> + <div class="pure-g"> + @if(ProjectOwnerId.fromUserId(user.uid) === project.owner.uid) { + <div class="pure-u-1-1 pure-u-md-1-1"> + <div class="l-box"> + <h4>@Messages("project.label.edit.title", label.name)</h4> + <div class="form-errors"> + @formErrors.get(fieldGlobal).map { es => + @for(error <- es) { + <p class="alert alert-error"> + <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span> + <span class="sr-only">Fehler:</span> + @error + </p> + } + } + </div> + <div class="edit-labels-form"> + <form action="@action" class="pure-form pure-form-aligned" method="POST" accept-charset="UTF-8"> + <fieldset> + <input type="hidden" id="@fieldId" name="@fieldId" readonly="" value="@label.id"> + <div class="pure-control-group"> + <label for="@{fieldName}">@Messages("form.label.name")</label> + <input id="@{fieldName}" name="@{fieldName}" maxlength="40" placeholder="@Messages("form.label.name.placeholder")" required="" type="text" value="@{formData.get(fieldName)}"> + <span class="pure-form-message" id="@{fieldName}.help" style="margin-left: 13em;">@Messages("form.label.name.help")</span> + @renderFormErrors(fieldName, formErrors) + </div> + <div class="pure-control-group"> + <label for="@{fieldDescription}">@Messages("form.label.description")</label> + <input class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" maxlength="254" placeholder="@Messages("form.label.description.placeholder")" type="text" value="@{formData.get(fieldDescription)}"> + <span class="pure-form-message" id="@{fieldDescription}.help" style="margin-left: 13em;">@Messages("form.label.description.help")</span> + @renderFormErrors(fieldDescription, formErrors) + </div> + <div class="pure-control-group"> + <label for="@{fieldColour}">@Messages("form.label.colour")</label> + <input id="@{fieldColour}" name="@{fieldColour}" required="" type="color" value="@{formData.get(fieldColour).getOrElse("#B48EAD")}"> + <span class="pure-form-message" id="@{fieldColour}.help" style="margin-left: 13em;">@Messages("form.label.colour.help")</span> + @renderFormErrors(fieldColour, formErrors) + </div> + @csrfToken(csrf) + <button type="submit" class="pure-button pure-button-success">@Messages("form.label.edit.button.submit")</button> + </fieldset> + </form> + </div> + </div> + </div> + } else { } + </div> + <div class="pure-g"> + <div class="pure-u-1-1 pure-u-md-1-1"> + <div class="l-box"> + </div> + </div> + </div> +</div> +} +} + diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editLabels.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editLabels.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editLabels.scala.html 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editLabels.scala.html 2025-01-31 10:46:28.182967326 +0000 @@ -0,0 +1,130 @@ +@import de.smederee.hub.Account +@import de.smederee.hub.views.html.main +@import de.smederee.tickets.LabelForm._ +@import de.smederee.tickets._ +@import de.smederee.tickets.forms._ +@import de.smederee.tickets.forms.types._ +@import de.smederee.tickets.views.html.forms.renderFormErrors +@import de.smederee.tickets.views.html.showProjectMenu + +@(baseUri: Uri = Uri(path = Uri.Path.Root), + lang: LanguageCode = LanguageCode("en") +)(action: Uri, + csrf: Option[CsrfToken] = None, + labels: List[Label], + projectBaseUri: Uri, + title: Option[String] = None, + user: Option[Account], + project: Project +)(formData: Map[String, String] = Map.empty, + formErrors: FormErrors = FormErrors.empty +) +@main(baseUri, lang)()(csrf, title, user) { +@defining(lang.toLocale) { implicit locale => +<div class="content"> + <div class="pure-g"> + <div class="pure-u-1"> + <div class="l-box-left-right"> + <h2><a href="@{baseUri.addSegment(s"~${project.owner.name}")}">~@project.owner.name</a>/@project.name</h2> + @showProjectMenu(baseUri)(action.some, projectBaseUri, user, project) + <div class="project-summary-description"> + @if(user.exists(account => ProjectOwnerId.fromUserId(account.uid) === project.owner.uid)) { + @Messages("project.labels.edit.title") + } else { + @Messages("project.labels.view.title") + } + </div> + </div> + </div> + </div> + <div class="pure-g"> + @if(user.exists(account => ProjectOwnerId.fromUserId(account.uid) === project.owner.uid)) { + <div class="pure-u-1-1 pure-u-md-1-1"> + <div class="l-box"> + <h4>@Messages("project.labels.add.title")</h4> + <div class="form-errors"> + @formErrors.get(fieldGlobal).map { es => + @for(error <- es) { + <p class="alert alert-error"> + <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span> + <span class="sr-only">Fehler:</span> + @error + </p> + } + } + </div> + <div class="edit-labels-form"> + <form action="@projectBaseUri.addSegment("labels")" class="pure-form pure-form-stacked" method="POST" accept-charset="UTF-8"> + <fieldset> + <div class="pure-control-group"> + <label for="@{fieldName}">@Messages("form.label.name")</label> + <input class="pure-input-3-4" id="@{fieldName}" name="@{fieldName}" maxlength="40" placeholder="@Messages("form.label.name.placeholder")" required="" type="text" value="@{formData.get(fieldName)}"> + <span class="pure-form-message" id="@{fieldName}.help">@Messages("form.label.name.help")</span> + @renderFormErrors(fieldName, formErrors) + </div> + <div class="pure-control-group"> + <label for="@{fieldDescription}">@Messages("form.label.description")</label> + <input class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" maxlength="254" placeholder="@Messages("form.label.description.placeholder")" type="text" value="@{formData.get(fieldDescription)}"> + <span class="pure-form-message" id="@{fieldDescription}.help">@Messages("form.label.description.help")</span> + @renderFormErrors(fieldDescription, formErrors) + </div> + <div class="pure-control-group"> + <label for="@{fieldColour}">@Messages("form.label.colour")</label> + <input id="@{fieldColour}" name="@{fieldColour}" required="" type="color" value="@{formData.get(fieldColour).getOrElse("#B48EAD")}"> + <span class="pure-form-message" id="@{fieldColour}.help">@Messages("form.label.colour.help")</span> + @renderFormErrors(fieldColour, formErrors) + </div> + @csrfToken(csrf) + <button type="submit" class="pure-button pure-button-success">@Messages("form.label.create.button.submit")</button> + </fieldset> + </form> + </div> + </div> + </div> + } else { } + </div> + <div class="pure-g"> + <div class="pure-u-1-1 pure-u-md-1-1"> + <div class="l-box"> + <div class="label-list"> + <h4>@Messages("project.labels.list.title", labels.size)</h4> + @if(labels.size === 0) { + <div class="alert alert-info">@Messages("project.labels.list.empty")</div> + } else { + @defining(32) { lineHeight => + @for(label <- labels) { + <div class="pure-g label"> + <div class="pure-u-1-24 label-icon" style="color: @label.colour;"> + @icon(baseUri)("tag", lineHeight.some) + </div> + <div class="pure-u-5-24 label-name" style="background: @label.colour; height: @{lineHeight}px; line-height: @{lineHeight}px;">@label.name</div> + <div class="pure-u-8-24 label-description" style="height: @{lineHeight}px; line-height: @{lineHeight}px;">@label.description</div> + <div class="pure-u-2-24" style="height: @{lineHeight}px; line-height: @{lineHeight}px; margin: auto;"> + @if(user.exists(account => ProjectOwnerId.fromUserId(account.uid) === project.owner.uid)) { + <a class="pure-button" href="@projectBaseUri.addSegment("label").addSegment(label.name.toString).addSegment("edit")" title="@Messages("project.label.edit.title", label.name)">@Messages("project.label.edit.link")</a> + } else { } + </div> + <div class="pure-u-8-24 label-form" style="height: @{lineHeight}px; line-height: @{lineHeight}px; padding-left: 1em;"> + @if(user.exists(account => ProjectOwnerId.fromUserId(account.uid) === project.owner.uid)) { + <form action="@projectBaseUri.addSegment("label").addSegment(label.name.toString).addSegment("delete")" class="pure-form" method="POST" accept-charset="UTF-8"> + <fieldset> + <input type="hidden" id="@fieldId-@label.name" name="@fieldId" readonly="" value="@label.id"> + <input type="hidden" id="@fieldName-@label.name" name="@fieldName" readonly="" value="@label.name"> + <label for="i-am-sure-@label.name"><input id="i-am-sure-@label.name" name="i-am-sure" required="" type="checkbox" value="yes"/> @Messages("form.label.delete.i-am-sure")</label> + @csrfToken(csrf) + <button type="submit" class="pure-button pure-button-warning">@Messages("form.label.delete.button.submit")</button> + </fieldset> + </form> + } else { } + </div> + </div> + } + } + } + </div> + </div> + </div> + </div> +</div> +} +} diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestone.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestone.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestone.scala.html 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestone.scala.html 2025-01-31 10:46:28.182967326 +0000 @@ -0,0 +1,92 @@ +@import de.smederee.hub.Account +@import de.smederee.hub.views.html.main +@import de.smederee.tickets.MilestoneForm._ +@import de.smederee.tickets._ +@import de.smederee.tickets.forms._ +@import de.smederee.tickets.forms.types._ +@import de.smederee.tickets.views.html.forms.renderFormErrors +@import de.smederee.tickets.views.html.showProjectMenu + +@(baseUri: Uri = Uri(path = Uri.Path.Root), + lang: LanguageCode = LanguageCode("en") +)(action: Uri, + csrf: Option[CsrfToken] = None, + milestone: Milestone, + projectBaseUri: Uri, + title: Option[String] = None, + user: Account, + project: Project +)(formData: Map[String, String] = Map.empty, + formErrors: FormErrors = FormErrors.empty +) +@main(baseUri, lang)()(csrf, title, user.some) { +@defining(lang.toLocale) { implicit locale => +<div class="content"> + <div class="pure-g"> + <div class="pure-u-1"> + <div class="l-box-left-right"> + <h2><a href="@{baseUri.addSegment(s"~${project.owner.name}")}">~@project.owner.name</a>/@project.name</h2> + @showProjectMenu(baseUri)(projectBaseUri.addSegment("milestones").some, projectBaseUri, user.some, project) + <div class="project-summary-description"> + @Messages("project.milestones.edit.title") + </div> + </div> + </div> + </div> + <div class="pure-g"> + @if(ProjectOwnerId.fromUserId(user.uid) === project.owner.uid) { + <div class="pure-u-1-1 pure-u-md-1-1"> + <div class="l-box"> + <h4>@Messages("project.milestone.edit.title", milestone.title)</h4> + <div class="form-errors"> + @formErrors.get(fieldGlobal).map { es => + @for(error <- es) { + <p class="alert alert-error"> + <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span> + <span class="sr-only">Fehler:</span> + @error + </p> + } + } + </div> + <div class="edit-milestones-form"> + <form action="@action" class="pure-form pure-form-aligned" method="POST" accept-charset="UTF-8"> + <fieldset> + <input type="hidden" id="@fieldId" name="@fieldId" readonly="" value="@milestone.id"> + <div class="pure-control-group"> + <milestone for="@{fieldTitle}">@Messages("form.milestone.title")</milestone> + <input id="@{fieldTitle}" name="@{fieldTitle}" maxlength="40" placeholder="@Messages("form.milestone.title.placeholder")" required="" type="text" value="@{formData.get(fieldTitle)}"> + <span class="pure-form-message" id="@{fieldTitle}.help" style="margin-left: 13em;">@Messages("form.milestone.title.help")</span> + @renderFormErrors(fieldTitle, formErrors) + </div> + <div class="pure-control-group"> + <milestone for="@{fieldDescription}">@Messages("form.milestone.description")</milestone> + <input class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" maxlength="254" placeholder="@Messages("form.milestone.description.placeholder")" type="text" value="@{formData.get(fieldDescription)}"> + <span class="pure-form-message" id="@{fieldDescription}.help" style="margin-left: 13em;">@Messages("form.milestone.description.help")</span> + @renderFormErrors(fieldDescription, formErrors) + </div> + <div class="pure-control-group"> + <milestone for="@{fieldDueDate}">@Messages("form.milestone.due-date")</milestone> + <input id="@{fieldDueDate}" name="@{fieldDueDate}" type="date" value="@{formData.get(fieldDueDate)}"> + <span class="pure-form-message" id="@{fieldDueDate}.help" style="margin-left: 13em;">@Messages("form.milestone.due-date.help")</span> + @renderFormErrors(fieldDueDate, formErrors) + </div> + @csrfToken(csrf) + <button type="submit" class="pure-button pure-button-success">@Messages("form.milestone.edit.button.submit")</button> + </fieldset> + </form> + </div> + </div> + </div> + } else { } + </div> + <div class="pure-g"> + <div class="pure-u-1-1 pure-u-md-1-1"> + <div class="l-box"> + </div> + </div> + </div> +</div> +} +} + diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestones.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestones.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestones.scala.html 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestones.scala.html 2025-01-31 10:46:28.182967326 +0000 @@ -0,0 +1,132 @@ +@import java.time._ +@import de.smederee.hub.Account +@import de.smederee.hub.views.html.main +@import de.smederee.tickets.MilestoneForm._ +@import de.smederee.tickets._ +@import de.smederee.tickets.forms._ +@import de.smederee.tickets.forms.types._ +@import de.smederee.tickets.views.html.format.formatDate +@import de.smederee.tickets.views.html.forms.renderFormErrors +@import de.smederee.tickets.views.html.showProjectMenu + +@(baseUri: Uri = Uri(path = Uri.Path.Root), + lang: LanguageCode = LanguageCode("en") +)(action: Uri, + csrf: Option[CsrfToken] = None, + milestones: List[Milestone], + projectBaseUri: Uri, + title: Option[String] = None, + user: Option[Account], + project: Project +)(formData: Map[String, String] = Map.empty, + formErrors: FormErrors = FormErrors.empty +) +@main(baseUri, lang)()(csrf, title, user) { +@defining(lang.toLocale) { implicit locale => +<div class="content"> + <div class="pure-g"> + <div class="pure-u-1"> + <div class="l-box-left-right"> + <h2><a href="@{baseUri.addSegment(s"~${project.owner.name}")}">~@project.owner.name</a>/@project.name</h2> + @showProjectMenu(baseUri)(action.some, projectBaseUri, user, project) + <div class="project-summary-description"> + @if(user.exists(account => ProjectOwnerId.fromUserId(account.uid) === project.owner.uid)) { + @Messages("project.milestones.edit.title") + } else { + @Messages("project.milestones.view.title") + } + </div> + </div> + </div> + </div> + <div class="pure-g"> + @if(user.exists(account => ProjectOwnerId.fromUserId(account.uid) === project.owner.uid)) { + <div class="pure-u-1-1 pure-u-md-1-1"> + <div class="l-box"> + <h4>@Messages("project.milestones.add.title")</h4> + <div class="form-errors"> + @formErrors.get(fieldGlobal).map { es => + @for(error <- es) { + <p class="alert alert-error"> + <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span> + <span class="sr-only">Fehler:</span> + @error + </p> + } + } + </div> + <div class="edit-milestones-form"> + <form action="@projectBaseUri.addSegment("milestones")" class="pure-form pure-form-stacked" method="POST" accept-charset="UTF-8"> + <fieldset> + <div class="pure-control-group"> + <label for="@{fieldTitle}">@Messages("form.milestone.title")</label> + <input class="pure-input-3-4" id="@{fieldTitle}" name="@{fieldTitle}" maxlength="40" placeholder="@Messages("form.milestone.title.placeholder")" required="" type="text" value="@{formData.get(fieldTitle)}"> + <span class="pure-form-message" id="@{fieldTitle}.help">@Messages("form.milestone.title.help")</span> + @renderFormErrors(fieldTitle, formErrors) + </div> + <div class="pure-control-group"> + <label for="@{fieldDescription}">@Messages("form.milestone.description")</label> + <textarea class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" placeholder="@Messages("form.milestone.description.placeholder")" rows="4" value="@{formData.get(fieldDescription)}"></textarea> + <span class="pure-form-message" id="@{fieldDescription}.help">@Messages("form.milestone.description.help")</span> + @renderFormErrors(fieldDescription, formErrors) + </div> + <div class="pure-control-group"> + <label for="@{fieldDueDate}">@Messages("form.milestone.due-date")</label> + <input id="@{fieldDueDate}" name="@{fieldDueDate}" type="date" value="@{formData.get(fieldDueDate)}"> + <span class="pure-form-message" id="@{fieldDueDate}.help">@Messages("form.milestone.due-date.help")</span> + @renderFormErrors(fieldDueDate, formErrors) + </div> + @csrfToken(csrf) + <button type="submit" class="pure-button pure-button-success">@Messages("form.milestone.create.button.submit")</button> + </fieldset> + </form> + </div> + </div> + </div> + } else { } + </div> + <div class="pure-g"> + <div class="pure-u-1-1 pure-u-md-1-1"> + <div class="l-box"> + <div class="milestone-list"> + <h4>@Messages("project.milestones.list.title", milestones.size)</h4> + @if(milestones.size === 0) { + <div class="alert alert-info">@Messages("project.milestones.list.empty")</div> + } else { + @defining(32) { lineHeight => + @for(milestone <- milestones) { + <div class="pure-g milestone"> + <div class="pure-u-1-24 milestone-icon"> + @icon(baseUri)("flag", lineHeight.some) + </div> + <div class="pure-u-5-24 milestone-title" style="height: @{lineHeight}px; line-height: @{lineHeight}px;">@milestone.title @for(dueDate <- milestone.dueDate) { @formatDate(dueDate) }</div> + <div class="pure-u-8-24 milestone-description" style="height: @{lineHeight}px; line-height: @{lineHeight}px;">@milestone.description</div> + <div class="pure-u-2-24" style="height: @{lineHeight}px; line-height: @{lineHeight}px; margin: auto;"> + @if(user.exists(account => ProjectOwnerId.fromUserId(account.uid) === project.owner.uid)) { + <a class="pure-button" href="@projectBaseUri.addSegment("milestone").addSegment(milestone.title.toString).addSegment("edit")" title="@Messages("project.milestone.edit.title", milestone.title)">@Messages("project.milestone.edit.link")</a> + } else { } + </div> + <div class="pure-u-8-24 milestone-form" style="height: @{lineHeight}px; line-height: @{lineHeight}px; padding-left: 1em;"> + @if(user.exists(account => ProjectOwnerId.fromUserId(account.uid) === project.owner.uid)) { + <form action="@projectBaseUri.addSegment("milestone").addSegment(milestone.title.toString).addSegment("delete")" class="pure-form" method="POST" accept-charset="UTF-8"> + <fieldset> + <input type="hidden" id="@fieldId-@milestone.title" name="@fieldId" readonly="" value="@milestone.id"> + <input type="hidden" id="@fieldTitle-@milestone.title" name="@fieldTitle" readonly="" value="@milestone.title"> + <milestone for="i-am-sure-@milestone.title"><input id="i-am-sure-@milestone.title" name="i-am-sure" required="" type="checkbox" value="yes"/> @Messages("form.milestone.delete.i-am-sure")</milestone> + @csrfToken(csrf) + <button type="submit" class="pure-button pure-button-warning">@Messages("form.milestone.delete.button.submit")</button> + </fieldset> + </form> + } else { } + </div> + </div> + } + } + } + </div> + </div> + </div> + </div> +</div> +} +} diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatDate.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatDate.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatDate.scala.html 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatDate.scala.html 2025-01-31 10:46:28.182967326 +0000 @@ -0,0 +1,6 @@ +@import java.time._ +@import java.time.format._ +@import java.util.Locale + +@(date: LocalDate, style: FormatStyle = FormatStyle.MEDIUM)(implicit locale: Locale) +(@DateTimeFormatter.ofLocalizedDate(style).withLocale(locale).format(date)) diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/forms/renderFormErrors.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/forms/renderFormErrors.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/forms/renderFormErrors.scala.html 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/forms/renderFormErrors.scala.html 2025-01-31 10:46:28.182967326 +0000 @@ -0,0 +1,12 @@ +@import de.smederee.tickets.forms.types._ + +@(field: FormField, errors: FormErrors) +@errors.get(field).map { fieldErrors => + <div class="alert alert-warning"> + <ul> + @for(error <- fieldErrors) { + <li><span>@error</span></li> + } + </ul> + </div> +} diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/icon.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/icon.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/icon.scala.html 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/icon.scala.html 2025-01-31 10:46:28.182967326 +0000 @@ -0,0 +1,4 @@ +@(baseUri: Uri = Uri(path = Uri.Path.Root))(icon: String, overrideSize: Option[Int] = None) +@defining(overrideSize.map(size => s"""style="height: ${size}px; width: ${size}px;"""").getOrElse("")) { sizeOverride => +<span class="feather-icon" aria-hidden="true"><svg class="feather-svg" @Html(sizeOverride)><use href="@{baseUri.addPath("assets/feather/4.29.0/feather-sprite.svg").withFragment(icon)}"/></svg></span> +} diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showProjectMenu.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showProjectMenu.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showProjectMenu.scala.html 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showProjectMenu.scala.html 2025-01-31 10:46:28.182967326 +0000 @@ -0,0 +1,32 @@ +@import de.smederee.hub._ +@import de.smederee.tickets.{ Project, ProjectOwnerId } + +@(baseUri: Uri +)(activeUri: Option[Uri], + projectBaseUri: Uri, + user: Option[Account] = None, + project: Project +)(implicit locale: java.util.Locale) +<nav class="pure-menu pure-menu-horizontal"> + <ul class="pure-menu-list"> + @defining(projectBaseUri) { uri => + <li class="pure-menu-item@if(activeUri.exists(_ === uri)){ pure-menu-active}else{}"><a class="pure-menu-link" href="@uri">@icon(baseUri)("eye") @Messages("project.menu.overview")</a></li> + } + @defining(projectBaseUri.addSegment("labels")) { uri => + <li class="pure-menu-item@if(activeUri.exists(_ === uri)){ pure-menu-active}else{}"><a class="pure-menu-link" href="@uri">@icon(baseUri)("tag") @Messages("project.menu.labels")</a></li> + } + @defining(projectBaseUri.addSegment("milestones")) { uri => + <li class="pure-menu-item@if(activeUri.exists(_ === uri)){ pure-menu-active}else{}"><a class="pure-menu-link" href="@uri">@icon(baseUri)("flag") @Messages("project.menu.milestones")</a></li> + } + @if(activeUri.exists(uri => uri === projectBaseUri || uri === projectBaseUri.addSegment("edit") || uri === projectBaseUri.addSegment("delete"))) { + @if(user.exists(account => ProjectOwnerId.fromUserId(account.uid) === project.owner.uid)) { + @defining(projectBaseUri.addSegment("edit")) { uri => + <li class="pure-menu-item@if(activeUri.exists(_ === uri)){ pure-menu-active}else{}"><a class="pure-menu-link" href="@uri">@icon(baseUri)("edit-2") @Messages("project.menu.edit")</a></li> + } + @defining(projectBaseUri.addSegment("delete")) { uri => + <li class="pure-menu-item@if(activeUri.exists(_ === uri)){ pure-menu-active}else{}"><a class="pure-menu-link" href="@uri">@icon(baseUri)("trash-2") @Messages("project.menu.delete")</a></li> + } + } else { } + } else { } + </ul> +</nav> diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/AuthenticationRoutesTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/AuthenticationRoutesTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/hub/AuthenticationRoutesTest.scala 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/AuthenticationRoutesTest.scala 2025-01-31 10:46:28.182967326 +0000 @@ -27,23 +27,21 @@ import de.smederee.hub.forms._ import de.smederee.hub.forms.types._ import de.smederee.security.{ SignAndValidate, SignedToken } -import fs2.Stream import org.http4s._ -import org.http4s.headers._ import org.http4s.implicits._ import org.http4s.server._ -import org.http4s.twirl.TwirlInstances._ import pureconfig.ConfigSource import munit._ -import org.scalacheck._ -import org.scalacheck.Prop._ class AuthenticationRoutesTest extends CatsEffectSuite { val loginPath = uri"/login" protected final val configuration: SmedereeHubConfig = - ConfigSource.fromConfig(ConfigFactory.load(getClass.getClassLoader)).loadOrThrow[SmedereeHubConfig] + ConfigSource + .fromConfig(ConfigFactory.load(getClass.getClassLoader)) + .at(SmedereeHubConfig.location) + .loadOrThrow[SmedereeHubConfig] test("GET /login must return the login form for guest users") { val expectedHtml = views.html.login()(loginPath, None, title = "Smederee - Login to your account".some)() @@ -271,12 +269,6 @@ .updateFormField(LoginForm.fieldPassword.toString, repo.DefaultPassword) def request = Request[IO](method = Method.POST, uri = loginPath).withEntity(payload) - val expectedHtml = - views.html.login()(loginPath, None, title = "Smederee - Login to your account".some)( - formData = Map(LoginForm.fieldName.toString -> account.name.toString), - Map(LoginForm.fieldGlobal -> List(FormFieldError("Invalid credentials!"))) - ) - def response: IO[Response[IO]] = service.orNotFound.run(request) val test = for { diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/config/ServiceConfigTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/config/ServiceConfigTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/hub/config/ServiceConfigTest.scala 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/config/ServiceConfigTest.scala 2025-01-31 10:46:28.182967326 +0000 @@ -34,7 +34,7 @@ test("must load from the default configuration successfully") { ConfigSource .fromConfig(rawDefaultConfig()) - .at(ServiceConfig.parentKey.toString) + .at(s"${SmedereeHubConfig.location.toString}.service") .load[ServiceConfig] match { case Left(errors) => fail(errors.toList.mkString(", ")) case Right(_) => assert(true) @@ -44,7 +44,7 @@ test("default configuration must have authentication enabled") { ConfigSource .fromConfig(rawDefaultConfig()) - .at(ServiceConfig.parentKey.toString) + .at(s"${SmedereeHubConfig.location.toString}.service") .load[ServiceConfig] match { case Left(errors) => fail(errors.toList.mkString(", ")) case Right(cfg) => assert(cfg.authentication.enabled) @@ -54,7 +54,7 @@ test("default configuration must have billing disabled") { ConfigSource .fromConfig(rawDefaultConfig()) - .at(ServiceConfig.parentKey.toString) + .at(s"${SmedereeHubConfig.location.toString}.service") .load[ServiceConfig] match { case Left(errors) => fail(errors.toList.mkString(", ")) case Right(cfg) => assert(cfg.billing.enabled === false) @@ -64,7 +64,7 @@ test("default configuration must have sign up enabled") { ConfigSource .fromConfig(rawDefaultConfig()) - .at(ServiceConfig.parentKey.toString) + .at(s"${SmedereeHubConfig.location.toString}.service") .load[ServiceConfig] match { case Left(errors) => fail(errors.toList.mkString(", ")) case Right(cfg) => assert(cfg.signup.enabled) @@ -74,7 +74,7 @@ test("default values for external linking must be setup for local development") { ConfigSource .fromConfig(rawDefaultConfig()) - .at(ServiceConfig.parentKey.toString) + .at(s"${SmedereeHubConfig.location.toString}.service") .load[ServiceConfig] match { case Left(errors) => fail(errors.toList.mkString(", ")) case Right(cfg) => diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala 2025-01-31 10:46:28.182967326 +0000 @@ -22,7 +22,8 @@ import java.util.{ Locale, UUID } import cats.syntax.all._ -import de.smederee.security.{ PrivateKey, SignAndValidate } +import de.smederee.email.EmailAddress +import de.smederee.security._ import org.scalacheck._ @@ -80,12 +81,19 @@ val genUUID: Gen[UUID] = Gen.delay(UUID.randomUUID) - val genValidEmail: Gen[Email] = - for { - length <- Gen.choose(4, 64) - chars <- Gen.nonEmptyListOf(Gen.alphaNumChar) - email = chars.take(length).mkString - } yield Email(email + "@example.com") + private val validEmailAddressPrefixChars = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.!#$%&’'*+/=?^_`{|}~-".toList + private val validDomainNameChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-".toList + + val genValidEmail: Gen[EmailAddress] = for { + prefix <- Gen.nonEmptyListOf(Gen.oneOf(validEmailAddressPrefixChars)).map(_.take(64).mkString) + domain <- Gen.nonEmptyListOf(Gen.oneOf(validDomainNameChars)).map(_.take(32).mkString) + topLevelDomain <- Gen + .nonEmptyListOf(Gen.oneOf(validDomainNameChars)) + .suchThat(_.length >= 2) + .map(_.take(24).mkString) + suffix = s"$domain.$topLevelDomain" + } yield EmailAddress(s"$prefix@$suffix") val genValidUsername: Gen[Username] = for { length <- Gen.choose(2, 30) diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/PasswordTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/PasswordTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/hub/PasswordTest.scala 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/PasswordTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,64 +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.nio.charset.StandardCharsets - -import cats.syntax.all._ -import com.typesafe.config._ -import pureconfig._ - -import munit._ -import org.scalacheck._ -import org.scalacheck.Prop._ - -final class PasswordTest extends ScalaCheckSuite { - val genPassword: Gen[Password] = - Gen.nonEmptyListOf(Gen.alphaNumChar).map(s => Password(s.mkString.getBytes(StandardCharsets.UTF_8))) - given Arbitrary[Password] = Arbitrary(genPassword) - - property("Password.from(null) must always be None") { - forAll { (_: String) => - assertEquals(Password.from(null), None) // scalafix:ok - } - } - - property("Password.from(string) must always be UTF8 bytes") { - forAll { (s: String) => - val expected = s.getBytes(StandardCharsets.UTF_8) - Password.from(s) match { - case None => fail("Must produce valid byte array from non null string!") - case Some(p) => - assert(p.toArray.sameElements(expected), "Generated byte array differs from expected one!") - } - } - } - - property("Password.validate must fail on trimmed string with less than 12 characters") { - forAll { (s: String) => - val input = s.take(11) - assert(Password.validate(input).isInvalid, "Passwords with less than 12 characters must be invalid!") - } - } - - property("encode and matches must be commutative") { - forAll { (p: Password) => - assert(p.matches(p.encode), "Encoded hash of same password must match!") - } - } -} diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/TestAuthenticationRepository.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/TestAuthenticationRepository.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/hub/TestAuthenticationRepository.scala 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/TestAuthenticationRepository.scala 2025-01-31 10:46:28.182967326 +0000 @@ -18,10 +18,12 @@ package de.smederee.hub import java.nio.charset.StandardCharsets -import java.util.UUID import cats.effect._ import cats.syntax.all._ +import de.smederee.email.EmailAddress +import de.smederee.hub._ +import de.smederee.security._ /** An implementation of a [[AuthenticationRepository]] for testing purposes. * @@ -85,7 +87,7 @@ override def findAccount(uid: UserId): F[Option[Account]] = Sync[F].pure(accounts.headOption) - override def findAccountByEmail(email: Email): F[Option[Account]] = Sync[F].pure(accounts.headOption) + override def findAccountByEmail(email: EmailAddress): F[Option[Account]] = Sync[F].pure(accounts.headOption) override def unlockAccount(uid: UserId): F[Int] = { locked = locked.filterNot(_ === uid) diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/UserIdTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/UserIdTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/hub/UserIdTest.scala 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/UserIdTest.scala 1970-01-01 00:00:00.000000000 +0000 @@ -1,41 +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.UUID - -import munit._ -import org.scalacheck._ -import org.scalacheck.Prop._ - -final class UserIdTest extends ScalaCheckSuite { - private val genUUID: Gen[UUID] = Gen.delay(UUID.randomUUID) - given Arbitrary[UUID] = Arbitrary(genUUID) - - property("UserId.fromString must fail on invalid input") { - forAll { (input: String) => - assert(UserId.fromString(input).isLeft) - } - } - - property("UserId.fromString must succeed on valid input") { - forAll { (uuid: UUID) => - assert(UserId.fromString(uuid.toString).isRight) - } - } -} diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/ssh/PublicSshKeyTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/ssh/PublicSshKeyTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/ssh/PublicSshKeyTest.scala 2025-01-31 10:46:28.162967293 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/ssh/PublicSshKeyTest.scala 2025-01-31 10:46:28.182967326 +0000 @@ -21,6 +21,7 @@ import java.util.UUID import de.smederee.hub._ +import de.smederee.security._ import munit._ diff -rN -u old-smederee/modules/i18n/src/main/scala/de/smederee/i18n/LanguageCode.scala new-smederee/modules/i18n/src/main/scala/de/smederee/i18n/LanguageCode.scala --- old-smederee/modules/i18n/src/main/scala/de/smederee/i18n/LanguageCode.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/i18n/src/main/scala/de/smederee/i18n/LanguageCode.scala 2025-01-31 10:46:28.182967326 +0000 @@ -0,0 +1,57 @@ +/* + * 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.i18n + +import scala.util.matching.Regex + +opaque type LanguageCode = String +object LanguageCode { + val FormatIso639: Regex = "^[a-z]{2,3}$".r + + /** Create an instance of LanguageCode from the given String type. + * + * @param source + * An instance of type String which will be returned as a LanguageCode. + * @return + * The appropriate instance of LanguageCode. + */ + def apply(source: String): LanguageCode = source + + /** Try to create an instance of LanguageCode from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a LanguageCode. + * @return + * An option to the successfully converted LanguageCode. + */ + def from(source: String): Option[LanguageCode] = + Option(source).map(FormatIso639.matches) match { + case Some(true) => Option(source) + case _ => None + } + + extension (code: LanguageCode) { + + /** Convert the language code into a [[java.util.Locale]] to be used for internationalisation functions. + * + * @return + * A locale retrieved via the `forLanguageTag` method of the Java Locale implementation. + */ + def toLocale: java.util.Locale = java.util.Locale.forLanguageTag(code.toString) + } +} diff -rN -u old-smederee/modules/i18n/src/test/scala/de/smederee/i18n/LanguageCodeTest.scala new-smederee/modules/i18n/src/test/scala/de/smederee/i18n/LanguageCodeTest.scala --- old-smederee/modules/i18n/src/test/scala/de/smederee/i18n/LanguageCodeTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/i18n/src/test/scala/de/smederee/i18n/LanguageCodeTest.scala 2025-01-31 10:46:28.182967326 +0000 @@ -0,0 +1,48 @@ +/* + * 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.i18n + +import java.util.Locale + +import munit._ +import org.scalacheck._ +import org.scalacheck.Prop._ + +final class LanguageCodeTest extends ScalaCheckSuite { + val genLocale: Gen[Locale] = Gen.oneOf(Locale.getAvailableLocales.toList) + given Arbitrary[Locale] = Arbitrary(genLocale) + + val genLanguageCode: Gen[LanguageCode] = genLocale.map(_.getISO3Language).filter(_.nonEmpty).map(LanguageCode.apply) + given Arbitrary[LanguageCode] = Arbitrary(genLanguageCode) + + property("from must work for correct language codes") { + forAll { (locale: Locale) => + LanguageCode.from(locale.getISO3Language) match { + case None => fail(s"No LanguageCode created from given input (locale: $locale)!") + case Some(languageCode) => assertEquals(languageCode.toString, locale.getISO3Language) + } + } + } + + property("toLocale must return correct locales") { + forAll { (code: LanguageCode) => + val expectedLocale = Locale.forLanguageTag(code.toString) + assertEquals(code.toLocale, expectedLocale) + } + } +} diff -rN -u old-smederee/modules/security/src/main/scala/de/smederee/security/CsrfToken.scala new-smederee/modules/security/src/main/scala/de/smederee/security/CsrfToken.scala --- old-smederee/modules/security/src/main/scala/de/smederee/security/CsrfToken.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/security/src/main/scala/de/smederee/security/CsrfToken.scala 2025-01-31 10:46:28.182967326 +0000 @@ -0,0 +1,44 @@ +/* + * 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.security + +opaque type CsrfToken = String +object CsrfToken { + + /** Create an instance of CsrfToken from the given String type. + * + * @param source + * An instance of type String which will be returned as a CsrfToken. + * @return + * The appropriate instance of CsrfToken. + */ + def apply(source: String): CsrfToken = source + + /** Try to create an instance of CsrfToken from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a CsrfToken. + * @return + * An option to the successfully converted CsrfToken. + */ + def from(source: String): Option[CsrfToken] = + Option(source).map(_.trim.nonEmpty) match { + case Some(true) => Option(source.trim) + case _ => None + } +} diff -rN -u old-smederee/modules/security/src/main/scala/de/smederee/security/PasswordHash.scala new-smederee/modules/security/src/main/scala/de/smederee/security/PasswordHash.scala --- old-smederee/modules/security/src/main/scala/de/smederee/security/PasswordHash.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/security/src/main/scala/de/smederee/security/PasswordHash.scala 2025-01-31 10:46:28.182967326 +0000 @@ -0,0 +1,47 @@ +/* + * 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.security + +import cats._ + +opaque type PasswordHash = String +object PasswordHash { + given Eq[PasswordHash] = Eq.fromUniversalEquals + + /** Create an instance of PasswordHash from the given String type. + * + * @param source + * An instance of type String which will be returned as a PasswordHash. + * @return + * The appropriate instance of PasswordHash. + */ + def apply(source: String): PasswordHash = source + + /** Try to create an instance of PasswordHash from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a PasswordHash. + * @return + * An option to the successfully converted PasswordHash. + */ + def from(source: String): Option[PasswordHash] = + Option(source).map(_.trim.nonEmpty) match { + case Some(true) => Option(source.trim) + case _ => None + } +} diff -rN -u old-smederee/modules/security/src/main/scala/de/smederee/security/Password.scala new-smederee/modules/security/src/main/scala/de/smederee/security/Password.scala --- old-smederee/modules/security/src/main/scala/de/smederee/security/Password.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/security/src/main/scala/de/smederee/security/Password.scala 2025-01-31 10:46:28.182967326 +0000 @@ -0,0 +1,80 @@ +/* + * 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.security + +import java.nio.charset.StandardCharsets + +import cats._ +import cats.data._ +import cats.syntax.all._ + +/** A password is stored as an `Array[Byte]` internally and its `validate(source: String)` function will check that the + * input has a minimum length. + */ +opaque type Password = Array[Byte] +object Password { + // The minimum length of a password to be considered valid. + val MinimumLength: Int = 12 + // The maximum length of a password might look contra-productive but it can prevent "long password denial of service attacks". + val MaximumLength: Int = 128 + + /** Create an instance of Password from the given Array[Byte] type. + * + * @param source + * An instance of type Array[Byte] which will be returned as a Password. + * @return + * The appropriate instance of Password. + */ + def apply(source: Array[Byte]): Password = source + + /** Try to create an instance of Password from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a Password. + * @return + * An option to the successfully converted Password. + */ + def from(source: String): Option[Password] = + Option(source) match { + case None => None + case Some(string) => Option(string.getBytes(StandardCharsets.UTF_8)) + } + + /** Validate the given String against the criteria for a valid password. + * + * @param source + * A string which should fulfil the password criteria. + * @return + * Either a list of errors or the validated Password. + */ + def validate(source: String): ValidatedNec[String, Password] = + Option(source).map(_.trim).filter(_.nonEmpty) match { + case Some(password) => + if (password.length < MinimumLength) + s"Password must be at least $MinimumLength characters long!".invalidNec + else if (password.length > MaximumLength) + s"Password must not be longer than $MaximumLength characters!".invalidNec + else + password.getBytes(StandardCharsets.UTF_8).validNec + case _ => "Password must not be empty!".invalidNec + } + + extension (p: Password) { + def toArray: Array[Byte] = p + } +} diff -rN -u old-smederee/modules/security/src/main/scala/de/smederee/security/PrivateKey.scala new-smederee/modules/security/src/main/scala/de/smederee/security/PrivateKey.scala --- old-smederee/modules/security/src/main/scala/de/smederee/security/PrivateKey.scala 2025-01-31 10:46:28.166967299 +0000 +++ new-smederee/modules/security/src/main/scala/de/smederee/security/PrivateKey.scala 2025-01-31 10:46:28.182967326 +0000 @@ -17,10 +17,7 @@ package de.smederee.security -import javax.crypto.spec.SecretKeySpec - opaque type PrivateKey = Array[Byte] - object PrivateKey { /** Create an instance of PrivateKey from the given Array[Byte] type. diff -rN -u old-smederee/modules/security/src/main/scala/de/smederee/security/UserId.scala new-smederee/modules/security/src/main/scala/de/smederee/security/UserId.scala --- old-smederee/modules/security/src/main/scala/de/smederee/security/UserId.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/security/src/main/scala/de/smederee/security/UserId.scala 2025-01-31 10:46:28.182967326 +0000 @@ -0,0 +1,78 @@ +/* + * 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.security + +import java.util.UUID + +import cats._ +import cats.syntax.all._ + +import scala.util.matching.Regex + +/** A user id is supposed to be globally unique and maps to the [[java.util.UUID]] type beneath. + */ +opaque type UserId = UUID +object UserId { + val Format: Regex = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$".r + + given Eq[UserId] = Eq.fromUniversalEquals + + /** Create an instance of UserId from the given UUID type. + * + * @param source + * An instance of type UUID which will be returned as a UserId. + * @return + * The appropriate instance of UserId. + */ + def apply(source: UUID): UserId = source + + /** Try to create an instance of UserId from the given UUID. + * + * @param source + * A UUID that should fulfil the requirements to be converted into a UserId. + * @return + * An option to the successfully converted UserId. + */ + def from(source: UUID): Option[UserId] = Option(source) + + /** Try to create an instance of UserId from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a UserId. + * @return + * An option to the successfully converted UserId. + */ + def fromString(source: String): Either[String, UserId] = + Option(source) + .filter(s => Format.matches(s)) + .flatMap { uuidString => + Either.catchNonFatal(UUID.fromString(uuidString)).toOption + } + .toRight("Illegal value for UserId!") + + /** Generate a new random user id. + * + * @return + * A user id which is pseudo randomly generated. + */ + def randomUserId: UserId = UUID.randomUUID + + extension (uid: UserId) { + def toUUID: UUID = uid + } +} diff -rN -u old-smederee/modules/security/src/main/scala/de/smederee/security/Username.scala new-smederee/modules/security/src/main/scala/de/smederee/security/Username.scala --- old-smederee/modules/security/src/main/scala/de/smederee/security/Username.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/security/src/main/scala/de/smederee/security/Username.scala 2025-01-31 10:46:28.182967326 +0000 @@ -0,0 +1,87 @@ +/* + * 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.security + +import cats._ +import cats.data._ +import cats.syntax.all._ + +import scala.util.matching.Regex + +/** A username for an account has to obey several restrictions which are similiar to the ones found for Unix usernames. + * It must start with a letter, contain only alphanumeric characters, be at least 2 and at most 31 characters long and + * be all lowercase. + */ +opaque type Username = String +object Username { + given Eq[Username] = Eq.fromUniversalEquals + + val MinimumLength: Int = 2 + val MaximumLength: Int = 31 + val isAlphanumeric: Regex = "^[a-z][a-z0-9]+$".r + + /** Create an instance of Username from the given String type. + * + * @param source + * An instance of type String which will be returned as a Username. + * @return + * The appropriate instance of Username. + */ + def apply(source: String): Username = source + + /** Try to create an instance of Username from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a Username. + * @return + * An option to the successfully converted Username. + */ + def from(s: String): Option[Username] = validate(s).toOption + + /** Validate the given string and return either the validated username or a list of errors. + * + * @param s + * An arbitrary string which should be a username. + * @return + * Either a list of errors or the validated username. + */ + def validate(s: String): ValidatedNec[String, Username] = + Option(s).map(_.trim.nonEmpty) match { + case Some(true) => + val input = s.trim + val miniumLength = + if (input.length >= MinimumLength) + input.validNec + else + s"Username too short (min. $MinimumLength characters)!".invalidNec + val maximumLength = + if (input.length <= MaximumLength) + input.validNec + else + s"Username too long (max. $MaximumLength characters)!".invalidNec + val alphanumeric = + if (isAlphanumeric.matches(input)) + input.validNec + else + "Username must be all lowercase alphanumeric characters and start with a character.".invalidNec + (miniumLength, maximumLength, alphanumeric).mapN { case (_, _, name) => + name + } + case _ => "Username must not be empty!".invalidNec + } +} diff -rN -u old-smederee/modules/security/src/test/scala/de/smederee/security/PasswordTest.scala new-smederee/modules/security/src/test/scala/de/smederee/security/PasswordTest.scala --- old-smederee/modules/security/src/test/scala/de/smederee/security/PasswordTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/security/src/test/scala/de/smederee/security/PasswordTest.scala 2025-01-31 10:46:28.182967326 +0000 @@ -0,0 +1,64 @@ +/* + * 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.security + +import java.nio.charset.StandardCharsets + +import munit._ +import org.scalacheck._ +import org.scalacheck.Prop._ + +final class PasswordTest extends ScalaCheckSuite { + property("Password.from(null) must always be None") { + forAll { (_: String) => + assertEquals(Password.from(null), None) // scalafix:ok + } + } + + property("Password.from(string) must always be UTF8 bytes") { + forAll { (s: String) => + val expected = s.getBytes(StandardCharsets.UTF_8) + Password.from(s) match { + case None => fail("Must produce valid byte array from non null string!") + case Some(p) => + assert(p.toArray.sameElements(expected), "Generated byte array differs from expected one!") + } + } + } + + property(s"Password.validate must fail on trimmed string that is too long") { + forAll { (input: String) => + if (input.trim.length > Password.MaximumLength) { + assert( + Password.validate(input).isInvalid, + s"Passwords with more than ${Password.MaximumLength} characters must be invalid!" + ) + } + } + } + + property(s"Password.validate must fail on trimmed string that is too short") { + forAll { (s: String) => + val input = s.take(Password.MinimumLength).drop(1) + assert( + Password.validate(input).isInvalid, + s"Passwords with less than ${Password.MinimumLength} characters must be invalid!" + ) + } + } +} diff -rN -u old-smederee/modules/security/src/test/scala/de/smederee/security/UserIdTest.scala new-smederee/modules/security/src/test/scala/de/smederee/security/UserIdTest.scala --- old-smederee/modules/security/src/test/scala/de/smederee/security/UserIdTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/security/src/test/scala/de/smederee/security/UserIdTest.scala 2025-01-31 10:46:28.182967326 +0000 @@ -0,0 +1,53 @@ +/* + * 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.security + +import java.util.UUID + +import cats.syntax.all._ + +import munit._ +import org.scalacheck._ +import org.scalacheck.Prop._ + +final class UserIdTest extends ScalaCheckSuite { + private val genUUID: Gen[UUID] = Gen.delay(UUID.randomUUID) + given Arbitrary[UUID] = Arbitrary(genUUID) + + property("UserId.fromString must fail on invalid input") { + forAll { (input: String) => + assert(UserId.fromString(input).isLeft) + } + } + + property("UserId.fromString must succeed on valid input") { + forAll { (uuid: UUID) => + assert(UserId.fromString(uuid.toString).isRight) + } + } + + property("Eq must hold") { + forAll { (id1: UUID, id2: UUID) => + val uid1 = UserId(id1) + val uid2 = UserId(id2) + + assert(uid1 === uid1, "Identical user ids must be considered equal!") + assert(uid1 =!= uid2, "Different user ids must not be considered equal!") + } + } +} diff -rN -u old-smederee/modules/tickets/src/it/resources/application.conf new-smederee/modules/tickets/src/it/resources/application.conf --- old-smederee/modules/tickets/src/it/resources/application.conf 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/it/resources/application.conf 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,12 @@ +tickets { + database { + host = localhost + host = ${?SMEDEREE_DB_HOST} + url = "jdbc:postgresql://"${tickets.database.host}":5432/smederee_tickets_it" + url = ${?SMEDEREE_TICKETS_TEST_DB_URL} + user = "smederee_tickets" + user = ${?SMEDEREE_TICKETS_TEST_DB_USER} + pass = "secret" + pass = ${?SMEDEREE_TICKETS_TEST_DB_PASS} + } +} diff -rN -u old-smederee/modules/tickets/src/it/resources/logback-test.xml new-smederee/modules/tickets/src/it/resources/logback-test.xml --- old-smederee/modules/tickets/src/it/resources/logback-test.xml 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/it/resources/logback-test.xml 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> +<configuration debug="false"> + <appender name="console" class="ch.qos.logback.core.ConsoleAppender"> + <filter class="ch.qos.logback.classic.filter.ThresholdFilter"> + <level>WARN</level> + </filter> + <encoder> + <pattern>%date %highlight(%-5level) %cyan(%logger{0}) - %msg%n</pattern> + </encoder> + </appender> + + <appender name="async-console" class="ch.qos.logback.classic.AsyncAppender"> + <appender-ref ref="console"/> + <queueSize>5000</queueSize> + <discardingThreshold>0</discardingThreshold> + </appender> + + <logger name="de.smederee.tickets" level="DEBUG" additivity="false"> + <appender-ref ref="async-console"/> + </logger> + + <logger name="org.flywaydb.core" level="ERROR" additivity="false"> + <appender-ref ref="async-console"/> + </logger> + + <root> + <appender-ref ref="async-console"/> + </root> +</configuration> diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala --- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,223 @@ +/* + * 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.tickets + +import java.net.ServerSocket + +import cats.effect._ +import cats.syntax.all._ +import com.comcast.ip4s._ +import com.typesafe.config.ConfigFactory +import de.smederee.tickets.config._ +import org.flywaydb.core.Flyway +import pureconfig._ + +import munit._ + +/** Base class for our integration test suites. + * + * It loads and provides the configuration as a resource suit fixture (loaded once for all tests within a suite) and + * does initialise the test database for each suite. The latter means a possibly existing database with the name + * configured **will be deleted**! + */ +abstract class BaseSpec extends CatsEffectSuite { + protected final val configuration: SmedereeTicketsConfiguration = + ConfigSource + .fromConfig(ConfigFactory.load(getClass.getClassLoader)) + .at(SmedereeTicketsConfiguration.location) + .loadOrThrow[SmedereeTicketsConfiguration] + + protected final val flyway: Flyway = + DatabaseMigrator + .configureFlyway(configuration.database.url, configuration.database.user, configuration.database.pass) + .cleanDisabled(false) + .load() + + /** Connect to the DBMS using the generic "template1" database which should always be present. + * + * @param dbConfig + * The database configuration. + * @return + * The connection to the database ("template1"). + */ + private def connect(dbConfig: DatabaseConfig): IO[java.sql.Connection] = + for { + _ <- IO(Class.forName(dbConfig.driver)) + database <- IO(dbConfig.url.split("/").reverse.take(1).mkString) + connection <- IO( + java.sql.DriverManager + .getConnection(dbConfig.url.replace(database, "template1"), dbConfig.user, dbConfig.pass) + ) + } yield connection + + override def beforeAll(): Unit = { + // Extract the database name from the URL. + val database = configuration.database.url.split("/").reverse.take(1).mkString + val db = Resource.make(connect(configuration.database))(con => IO(con.close())) + // Create the test database if it does not already exist. + db.use { connection => + for { + statement <- IO(connection.createStatement()) + exists <- IO( + statement.executeQuery( + s"""SELECT datname FROM pg_catalog.pg_database WHERE datname = '$database'""" + ) + ) + _ <- IO { + if (!exists.next()) + statement.execute(s"""CREATE DATABASE "$database"""") + } + _ <- IO(exists.close) + _ <- IO(statement.close) + } yield () + }.unsafeRunSync() + } + + override def afterAll(): Unit = { + // Extract the database name from the URL. + val database = configuration.database.url.split("/").reverse.take(1).mkString + val db = Resource.make(connect(configuration.database))(con => IO(con.close())) + // Drop the test database after all tests have been run. + db.use { connection => + for { + statement <- IO(connection.createStatement()) + _ <- IO(statement.execute(s"""DROP DATABASE IF EXISTS $database""")) + _ <- IO(statement.close) + } yield () + }.unsafeRunSync() + } + + override def beforeEach(context: BeforeEach): Unit = { + val _ = flyway.migrate() + } + + override def afterEach(context: AfterEach): Unit = { + val _ = flyway.clean() + } + + /** Find and return a free port on the local machine by starting a server socket and closing it. The port number used + * by the socket is marked to allow reuse, considered free and returned. + * + * @return + * An optional port number if a free one can be found. + */ + protected def findFreePort(): Option[Port] = { + val socket = new ServerSocket(0) + val port = socket.getLocalPort + socket.setReuseAddress(true) // Allow instant rebinding of the socket. + socket.close() // Free the socket for further use by closing it. + Port.fromInt(port) + } + + /** Provide a resource with a database connection to allow db operations and proper resource release later. + * + * @param cfg + * The application configuration. + * @return + * A cats resource encapsulation a database connection as defined within the given configuration. + */ + protected def connectToDb(cfg: SmedereeTicketsConfiguration): Resource[IO, java.sql.Connection] = + Resource.make( + IO.delay(java.sql.DriverManager.getConnection(cfg.database.url, cfg.database.user, cfg.database.pass)) + )(c => IO.delay(c.close())) + + /** Create a project for ticket tracking in the database. + * + * @param project + * The project to be created. + * @return + * The number of affected database rows. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + protected def createTicketsProject(project: Project): IO[Int] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay { + con.prepareStatement( + """INSERT INTO "tickets"."projects" (name, owner, is_private, description, created_at, updated_at) VALUES(?, ?, ?, ?, NOW(), NOW())""" + ) + } + _ <- IO.delay(statement.setString(1, project.name.toString)) + _ <- IO.delay(statement.setObject(2, project.owner.uid)) + _ <- IO.delay(statement.setBoolean(3, project.isPrivate)) + _ <- IO.delay( + project.description.fold(statement.setNull(4, java.sql.Types.VARCHAR))(descr => + statement.setString(4, descr.toString) + ) + ) + r <- IO.delay(statement.executeUpdate()) + _ <- IO.delay(statement.close()) + } yield r + } + + /** Create a tickets user account in the database. + * + * @param user + * The user to be created. + * @return + * The number of affected database rows. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + protected def createTicketsUser(user: ProjectOwner): IO[Int] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay { + con.prepareStatement( + """INSERT INTO "tickets"."users" (uid, name, email, created_at, updated_at) VALUES(?, ?, ?, NOW(), NOW())""" + ) + } + _ <- IO.delay(statement.setObject(1, user.uid)) + _ <- IO.delay(statement.setString(2, user.name.toString)) + _ <- IO.delay(statement.setString(3, user.email.toString)) + r <- IO.delay(statement.executeUpdate()) + _ <- IO.delay(statement.close()) + } yield r + } + + /** Find the repository ID for the given owner and repository name. + * + * @param owner + * The unique ID of the user account that owns the repository. + * @param name + * The repository name which must be unique in regard to the owner. + * @return + * An option to the internal database ID. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + protected def loadProjectId(owner: ProjectOwnerId, name: ProjectName): IO[Option[Long]] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay( + con.prepareStatement( + """SELECT id FROM "tickets"."projects" WHERE owner = ? AND name = ? LIMIT 1""" + ) + ) + _ <- IO.delay(statement.setObject(1, owner)) + _ <- IO.delay(statement.setString(2, name.toString)) + result <- IO.delay(statement.executeQuery) + account <- IO.delay { + if (result.next()) { + Option(result.getLong("id")) + } else { + None + } + } + _ <- IO(statement.close()) + } yield account + } +} diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala --- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/config/DatabaseMigratorTest.scala 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,65 @@ +/* + * 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.tickets.config + +import cats.effect._ +import cats.syntax.all._ +import org.flywaydb.core.Flyway + +import de.smederee.tickets.BaseSpec + +final class DatabaseMigratorTest extends BaseSpec { + override def beforeEach(context: BeforeEach): Unit = { + val dbConfig = configuration.database + val flyway: Flyway = + DatabaseMigrator.configureFlyway(dbConfig.url, dbConfig.user, dbConfig.pass).cleanDisabled(false).load() + val _ = flyway.migrate() + val _ = flyway.clean() + } + + override def afterEach(context: AfterEach): Unit = { + val dbConfig = configuration.database + val flyway: Flyway = + DatabaseMigrator.configureFlyway(dbConfig.url, dbConfig.user, dbConfig.pass).cleanDisabled(false).load() + val _ = flyway.migrate() + val _ = flyway.clean() + } + + test("DatabaseMigrator must update available outdated database") { + val dbConfig = configuration.database + val migrator = new DatabaseMigrator[IO] + val test = migrator.migrate(dbConfig.url, dbConfig.user, dbConfig.pass) + test.map(result => assert(result.migrationsExecuted > 0)) + } + + test("DatabaseMigrator must not update an up to date database") { + val dbConfig = configuration.database + val migrator = new DatabaseMigrator[IO] + val test = for { + _ <- migrator.migrate(dbConfig.url, dbConfig.user, dbConfig.pass) + r <- migrator.migrate(dbConfig.url, dbConfig.user, dbConfig.pass) + } yield r + test.map(result => assert(result.migrationsExecuted === 0)) + } + + test("DatabaseMigrator must throw an exception if the database is not available") { + val migrator = new DatabaseMigrator[IO] + val test = migrator.migrate("jdbc:nodriver://", "", "") + test.attempt.map(r => assert(r.isLeft)) + } +} diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala --- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,268 @@ +/* + * 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.tickets + +import cats.effect._ +import cats.syntax.all._ +import de.smederee.tickets.Generators._ +import doobie._ + +final class DoobieLabelRepositoryTest extends BaseSpec { + + /** Find the label ID for the given project and label name. + * + * @param owner + * The unique ID of the user account that owns the project. + * @param vcsRepoName + * The project name which must be unique in regard to the owner. + * @param labelName + * The label name which must be unique in the project context. + * @return + * An option to the internal database ID. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + protected def findLabelId(owner: ProjectOwnerId, vcsRepoName: ProjectName, labelName: LabelName): IO[Option[Long]] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay( + con.prepareStatement( + """SELECT "labels".id + |FROM "tickets"."labels" AS "labels" + |JOIN "tickets"."projects" AS "projects" + |ON "labels".project = "projects".id + |WHERE "projects".owner = ? + |AND "projects".name = ? + |AND "labels".name = ?""".stripMargin + ) + ) + _ <- IO.delay(statement.setObject(1, owner)) + _ <- IO.delay(statement.setString(2, vcsRepoName.toString)) + _ <- IO.delay(statement.setString(3, labelName.toString)) + result <- IO.delay(statement.executeQuery) + account <- IO.delay { + if (result.next()) { + Option(result.getLong("id")) + } else { + None + } + } + _ <- IO(statement.close()) + } yield account + } + + test("allLabels must return all labels") { + (genValidProjectOwner.sample, genValidProject.sample, genLabels.sample) match { + case (Some(owner), Some(generatedProject), Some(labels)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val labelRepo = new DoobieLabelRepository[IO](tx) + val test = for { + _ <- createTicketsUser(owner) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + createdLabels <- projectId match { + case None => IO.pure(List.empty) + case Some(projectId) => labels.traverse(label => labelRepo.createLabel(projectId)(label)) + } + foundLabels <- projectId match { + case None => IO.pure(List.empty) + case Some(projectId) => labelRepo.allLabels(projectId).compile.toList + } + } yield foundLabels + test.map { foundLabels => + assert(foundLabels.size === labels.size, "Different number of labels!") + foundLabels.sortBy(_.name).zip(labels.sortBy(_.name)).map { (found, expected) => + assertEquals(found.copy(id = expected.id), expected) + } + } + case _ => fail("Could not generate data samples!") + } + } + + test("createLabel must create the label") { + (genValidProjectOwner.sample, genValidProject.sample, genLabel.sample) match { + case (Some(owner), Some(generatedProject), Some(label)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val labelRepo = new DoobieLabelRepository[IO](tx) + val test = for { + _ <- createTicketsUser(owner) + createdProjects <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + createdLabels <- projectId.traverse(id => labelRepo.createLabel(id)(label)) + foundLabel <- projectId.traverse(id => labelRepo.findLabel(id)(label.name)) + } yield (createdProjects, projectId, createdLabels, foundLabel) + test.map { tuple => + val (createdProjects, projectId, createdLabels, foundLabel) = tuple + assert(createdProjects === 1, "Test project was not created!") + assert(projectId.nonEmpty, "No project id found!") + assert(createdLabels.exists(_ === 1), "Test label was not created!") + foundLabel.getOrElse(None) match { + case None => fail("Created label not found!") + case Some(foundLabel) => + assertEquals(foundLabel.name, label.name) + assertEquals(foundLabel.description, label.description) + assertEquals(foundLabel.colour, label.colour) + } + } + case _ => fail("Could not generate data samples!") + } + } + + test("createLabel must fail if the label name already exists") { + (genValidProjectOwner.sample, genValidProject.sample, genLabel.sample) match { + case (Some(owner), Some(generatedProject), Some(label)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val labelRepo = new DoobieLabelRepository[IO](tx) + val test = for { + _ <- createTicketsUser(owner) + createdProjects <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + createdLabels <- projectId.traverse(id => labelRepo.createLabel(id)(label)) + _ <- projectId.traverse(id => labelRepo.createLabel(id)(label)) + } yield (createdProjects, projectId, createdLabels) + test.attempt.map(r => assert(r.isLeft, "Creating a label with a duplicate name must fail!")) + case _ => fail("Could not generate data samples!") + } + } + + test("deleteLabel must delete an existing label") { + (genValidProjectOwner.sample, genValidProject.sample, genLabel.sample) match { + case (Some(owner), Some(generatedProject), Some(label)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val labelRepo = new DoobieLabelRepository[IO](tx) + val test = for { + _ <- createTicketsUser(owner) + createdProjects <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + createdLabels <- projectId.traverse(id => labelRepo.createLabel(id)(label)) + labelId <- findLabelId(owner.uid, project.name, label.name) + deletedLabels <- labelRepo.deleteLabel(label.copy(id = labelId.flatMap(LabelId.from))) + foundLabel <- projectId.traverse(id => labelRepo.findLabel(id)(label.name)) + } yield (createdProjects, projectId, createdLabels, deletedLabels, foundLabel) + test.map { tuple => + val (createdProjects, projectId, createdLabels, deletedLabels, foundLabel) = tuple + assert(createdProjects === 1, "Test vcs project was not created!") + assert(projectId.nonEmpty, "No vcs project id found!") + assert(createdLabels.exists(_ === 1), "Test label was not created!") + assert(deletedLabels === 1, "Test label was not deleted!") + assert(foundLabel.getOrElse(None).isEmpty, "Test label was not deleted!") + } + case _ => fail("Could not generate data samples!") + } + } + + test("findLabel must find existing labels") { + (genValidProjectOwner.sample, genValidProject.sample, genLabels.sample) match { + case (Some(owner), Some(generatedProject), Some(labels)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val expectedLabel = labels(scala.util.Random.nextInt(labels.size)) + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val labelRepo = new DoobieLabelRepository[IO](tx) + val test = for { + _ <- createTicketsUser(owner) + createdProjects <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + createdLabels <- projectId match { + case None => IO.pure(List.empty) + case Some(projectId) => labels.traverse(label => labelRepo.createLabel(projectId)(label)) + } + foundLabel <- projectId.traverse(id => labelRepo.findLabel(id)(expectedLabel.name)) + } yield foundLabel.flatten + test.map { foundLabel => + assertEquals(foundLabel.map(_.copy(id = expectedLabel.id)), Option(expectedLabel)) + } + case _ => fail("Could not generate data samples!") + } + } + + test("updateLabel must update an existing label") { + (genValidProjectOwner.sample, genValidProject.sample, genLabel.sample) match { + case (Some(owner), Some(generatedProject), Some(label)) => + val updatedLabel = label.copy( + name = LabelName("updated label"), + description = Option(LabelDescription("I am an updated label description...")), + colour = ColourCode("#abcdef") + ) + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val labelRepo = new DoobieLabelRepository[IO](tx) + val test = for { + _ <- createTicketsUser(owner) + createdProjects <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + createdLabels <- projectId.traverse(id => labelRepo.createLabel(id)(label)) + labelId <- findLabelId(owner.uid, project.name, label.name) + updatedLabels <- labelRepo.updateLabel(updatedLabel.copy(id = labelId.map(LabelId.apply))) + foundLabel <- projectId.traverse(id => labelRepo.findLabel(id)(updatedLabel.name)) + } yield (createdProjects, projectId, createdLabels, updatedLabels, foundLabel.flatten) + test.map { tuple => + val (createdProjects, projectId, createdLabels, updatedLabels, foundLabel) = tuple + assert(createdProjects === 1, "Test vcs project was not created!") + assert(projectId.nonEmpty, "No vcs project id found!") + assert(createdLabels.exists(_ === 1), "Test label was not created!") + assert(updatedLabels === 1, "Test label was not updated!") + assert(foundLabel.nonEmpty, "Updated label not found!") + foundLabel.map { label => + assertEquals(label, updatedLabel.copy(id = label.id)) + } + } + case _ => fail("Could not generate data samples!") + } + } + + test("updateLabel must do nothing if id attribute is empty") { + (genValidProjectOwner.sample, genValidProject.sample, genLabel.sample) match { + case (Some(owner), Some(generatedProject), Some(label)) => + val updatedLabel = label.copy( + id = None, + name = LabelName("updated label"), + description = Option(LabelDescription("I am an updated label description...")), + colour = ColourCode("#abcdef") + ) + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val labelRepo = new DoobieLabelRepository[IO](tx) + val test = for { + _ <- createTicketsUser(owner) + createdProjects <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + createdLabels <- projectId.traverse(id => labelRepo.createLabel(id)(label)) + labelId <- findLabelId(owner.uid, project.name, label.name) + updatedLabels <- labelRepo.updateLabel(updatedLabel) + } yield (createdProjects, projectId, createdLabels, updatedLabels) + test.map { tuple => + val (createdProjects, projectId, createdLabels, updatedLabels) = tuple + assert(createdProjects === 1, "Test vcs project was not created!") + assert(projectId.nonEmpty, "No vcs project id found!") + assert(createdLabels.exists(_ === 1), "Test label was not created!") + assert(updatedLabels === 0, "Label with empty id must not be updated!") + } + case _ => fail("Could not generate data samples!") + } + } +} diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala --- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,267 @@ +/* + * 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.tickets + +import java.time._ + +import cats.effect._ +import cats.syntax.all._ +import de.smederee.tickets.Generators._ +import doobie._ + +final class DoobieMilestoneRepositoryTest extends BaseSpec { + + /** Find the milestone ID for the given repository and milestone title. + * + * @param owner + * The unique ID of the user owner that owns the repository. + * @param projectName + * The project name which must be unique in regard to the owner. + * @param title + * The milestone title which must be unique in the repository context. + * @return + * An option to the internal database ID. + */ + @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!") + protected def findMilestoneId( + owner: ProjectOwnerId, + projectName: ProjectName, + title: MilestoneTitle + ): IO[Option[Long]] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay( + con.prepareStatement( + """SELECT "milestones".id FROM "tickets"."milestones" AS "milestones" JOIN "tickets"."projects" AS "projects" ON "milestones".project = "projects".id WHERE "projects".owner = ? AND "projects".name = ? AND "milestones".title = ?""" + ) + ) + _ <- IO.delay(statement.setObject(1, owner)) + _ <- IO.delay(statement.setString(2, projectName.toString)) + _ <- IO.delay(statement.setString(3, title.toString)) + result <- IO.delay(statement.executeQuery) + owner <- IO.delay { + if (result.next()) { + Option(result.getLong("id")) + } else { + None + } + } + _ <- IO(statement.close()) + } yield owner + } + + test("allMilestones must return all milestones") { + (genValidProjectOwner.sample, genValidProject.sample, genMilestones.sample) match { + case (Some(owner), Some(generatedProject), Some(milestones)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val milestoneRepo = new DoobieMilestoneRepository[IO](tx) + val test = for { + _ <- createTicketsUser(owner) + createdRepos <- createTicketsProject(project) + repoId <- loadProjectId(owner.uid, project.name) + createdMilestones <- repoId match { + case None => IO.pure(List.empty) + case Some(repoId) => milestones.traverse(milestone => milestoneRepo.createMilestone(repoId)(milestone)) + } + foundMilestones <- repoId match { + case None => IO.pure(List.empty) + case Some(repoId) => milestoneRepo.allMilestones(repoId).compile.toList + } + } yield foundMilestones + test.map { foundMilestones => + assert(foundMilestones.size === milestones.size, "Different number of milestones!") + foundMilestones.sortBy(_.title).zip(milestones.sortBy(_.title)).map { (found, expected) => + assertEquals(found.copy(id = expected.id), expected) + } + } + case _ => fail("Could not generate data samples!") + } + } + + test("createMilestone must create the milestone") { + (genValidProjectOwner.sample, genValidProject.sample, genMilestone.sample) match { + case (Some(owner), Some(generatedProject), Some(milestone)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val milestoneRepo = new DoobieMilestoneRepository[IO](tx) + val test = for { + _ <- createTicketsUser(owner) + createdRepos <- createTicketsProject(project) + repoId <- loadProjectId(owner.uid, project.name) + createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone)) + foundMilestone <- repoId.traverse(id => milestoneRepo.findMilestone(id)(milestone.title)) + } yield (createdRepos, repoId, createdMilestones, foundMilestone) + test.map { tuple => + val (createdRepos, repoId, createdMilestones, foundMilestone) = tuple + assert(createdRepos === 1, "Test vcs generatedProject was not created!") + assert(repoId.nonEmpty, "No vcs generatedProject id found!") + assert(createdMilestones.exists(_ === 1), "Test milestone was not created!") + foundMilestone.getOrElse(None) match { + case None => fail("Created milestone not found!") + case Some(foundMilestone) => assertEquals(foundMilestone, milestone.copy(id = foundMilestone.id)) + } + } + case _ => fail("Could not generate data samples!") + } + } + + test("createMilestone must fail if the milestone name already exists") { + (genValidProjectOwner.sample, genValidProject.sample, genMilestone.sample) match { + case (Some(owner), Some(generatedProject), Some(milestone)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val milestoneRepo = new DoobieMilestoneRepository[IO](tx) + val test = for { + _ <- createTicketsUser(owner) + createdRepos <- createTicketsProject(project) + repoId <- loadProjectId(owner.uid, project.name) + createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone)) + _ <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone)) + } yield (createdRepos, repoId, createdMilestones) + test.attempt.map(r => assert(r.isLeft, "Creating a milestone with a duplicate name must fail!")) + case _ => fail("Could not generate data samples!") + } + } + + test("deleteMilestone must delete an existing milestone") { + (genValidProjectOwner.sample, genValidProject.sample, genMilestone.sample) match { + case (Some(owner), Some(generatedProject), Some(milestone)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val milestoneRepo = new DoobieMilestoneRepository[IO](tx) + val test = for { + _ <- createTicketsUser(owner) + createdRepos <- createTicketsProject(project) + repoId <- loadProjectId(owner.uid, project.name) + createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone)) + milestoneId <- findMilestoneId(owner.uid, project.name, milestone.title) + deletedMilestones <- milestoneRepo.deleteMilestone(milestone.copy(id = milestoneId.flatMap(MilestoneId.from))) + foundMilestone <- repoId.traverse(id => milestoneRepo.findMilestone(id)(milestone.title)) + } yield (createdRepos, repoId, createdMilestones, deletedMilestones, foundMilestone) + test.map { tuple => + val (createdRepos, repoId, createdMilestones, deletedMilestones, foundMilestone) = tuple + assert(createdRepos === 1, "Test vcs generatedProject was not created!") + assert(repoId.nonEmpty, "No vcs generatedProject id found!") + assert(createdMilestones.exists(_ === 1), "Test milestone was not created!") + assert(deletedMilestones === 1, "Test milestone was not deleted!") + assert(foundMilestone.getOrElse(None).isEmpty, "Test milestone was not deleted!") + } + case _ => fail("Could not generate data samples!") + } + } + + test("findMilestone must find existing milestones") { + (genValidProjectOwner.sample, genValidProject.sample, genMilestones.sample) match { + case (Some(owner), Some(generatedProject), Some(milestones)) => + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val expectedMilestone = milestones(scala.util.Random.nextInt(milestones.size)) + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val milestoneRepo = new DoobieMilestoneRepository[IO](tx) + val test = for { + _ <- createTicketsUser(owner) + createdRepos <- createTicketsProject(project) + repoId <- loadProjectId(owner.uid, project.name) + createdMilestones <- repoId match { + case None => IO.pure(List.empty) + case Some(repoId) => milestones.traverse(milestone => milestoneRepo.createMilestone(repoId)(milestone)) + } + foundMilestone <- repoId.traverse(id => milestoneRepo.findMilestone(id)(expectedMilestone.title)) + } yield foundMilestone.flatten + test.map { foundMilestone => + assertEquals(foundMilestone.map(_.copy(id = expectedMilestone.id)), Option(expectedMilestone)) + } + case _ => fail("Could not generate data samples!") + } + } + + test("updateMilestone must update an existing milestone") { + (genValidProjectOwner.sample, genValidProject.sample, genMilestone.sample) match { + case (Some(owner), Some(generatedProject), Some(milestone)) => + val updatedMilestone = milestone.copy( + title = MilestoneTitle("updated milestone"), + description = Option(MilestoneDescription("I am an updated milestone description...")), + dueDate = Option(LocalDate.of(1879, 3, 14)) + ) + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val milestoneRepo = new DoobieMilestoneRepository[IO](tx) + val test = for { + _ <- createTicketsUser(owner) + createdRepos <- createTicketsProject(project) + repoId <- loadProjectId(owner.uid, project.name) + createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone)) + milestoneId <- findMilestoneId(owner.uid, project.name, milestone.title) + updatedMilestones <- milestoneRepo.updateMilestone( + updatedMilestone.copy(id = milestoneId.map(MilestoneId.apply)) + ) + foundMilestone <- repoId.traverse(id => milestoneRepo.findMilestone(id)(updatedMilestone.title)) + } yield (createdRepos, repoId, createdMilestones, updatedMilestones, foundMilestone.flatten) + test.map { tuple => + val (createdRepos, repoId, createdMilestones, updatedMilestones, foundMilestone) = tuple + assert(createdRepos === 1, "Test vcs generatedProject was not created!") + assert(repoId.nonEmpty, "No vcs generatedProject id found!") + assert(createdMilestones.exists(_ === 1), "Test milestone was not created!") + assert(updatedMilestones === 1, "Test milestone was not updated!") + assert(foundMilestone.nonEmpty, "Updated milestone not found!") + foundMilestone.map { milestone => + assertEquals(milestone, updatedMilestone.copy(id = milestone.id)) + } + } + case _ => fail("Could not generate data samples!") + } + } + + test("updateMilestone must do nothing if id attribute is empty") { + (genValidProjectOwner.sample, genValidProject.sample, genMilestone.sample) match { + case (Some(owner), Some(generatedProject), Some(milestone)) => + val updatedMilestone = milestone.copy( + id = None, + title = MilestoneTitle("updated milestone"), + description = Option(MilestoneDescription("I am an updated milestone description...")), + dueDate = Option(LocalDate.of(1879, 3, 14)) + ) + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val milestoneRepo = new DoobieMilestoneRepository[IO](tx) + val test = for { + _ <- createTicketsUser(owner) + createdRepos <- createTicketsProject(project) + repoId <- loadProjectId(owner.uid, project.name) + createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone)) + milestoneId <- findMilestoneId(owner.uid, project.name, milestone.title) + updatedMilestones <- milestoneRepo.updateMilestone(updatedMilestone) + } yield (createdRepos, repoId, createdMilestones, updatedMilestones) + test.map { tuple => + val (createdRepos, repoId, createdMilestones, updatedMilestones) = tuple + assert(createdRepos === 1, "Test vcs generatedProject was not created!") + assert(repoId.nonEmpty, "No vcs generatedProject id found!") + assert(createdMilestones.exists(_ === 1), "Test milestone was not created!") + assert(updatedMilestones === 0, "Milestone with empty id must not be updated!") + } + case _ => fail("Could not generate data samples!") + } + } +} diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/Generators.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/Generators.scala --- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/Generators.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/Generators.scala 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,203 @@ +/* + * 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.tickets + +import java.time._ +import java.util.{ Locale, UUID } + +import cats._ +import cats.syntax.all._ +import de.smederee.email.EmailAddress + +import org.scalacheck.{ Arbitrary, Gen } + +import scala.jdk.CollectionConverters._ + +object Generators { + val MinimumYear: Int = -4713 // Lowest year supported by PostgreSQL + val MaximumYear: Int = 294276 // Highest year supported by PostgreSQL + + /** Prepend a zero to a single character hexadecimal code. + * + * @param hexCode + * A string supposed to contain a hexadecimal code between 0 and ff. + * @return + * Either the given code prepended with a leading zero if it had only a single character or the originally given + * code otherwise. + */ + private def hexPadding(hexCode: String): String = + if (hexCode.length < 2) + "0" + hexCode + else + hexCode + + val genLocalDate: Gen[LocalDate] = + for { + year <- Gen.choose(MinimumYear, MaximumYear) + month <- Gen.choose(1, 12) + day <- Gen.choose(1, Month.of(month).length(Year.of(year).isLeap)) + } yield LocalDate.of(year, month, day) + + given Arbitrary[LocalDate] = Arbitrary(genLocalDate) + + val genOffsetDateTime: Gen[OffsetDateTime] = + for { + year <- Gen.choose(MinimumYear, MaximumYear) + month <- Gen.choose(1, 12) + day <- Gen.choose(1, Month.of(month).length(Year.of(year).isLeap)) + hour <- Gen.choose(0, 23) + minute <- Gen.choose(0, 59) + second <- Gen.choose(0, 59) + nanosecond <- Gen.const(0) // Avoid issues with loosing information during saving and loading. + offset <- Gen.oneOf( + ZoneId.getAvailableZoneIds.asScala.toList.map(ZoneId.of).map(ZonedDateTime.now).map(_.getOffset) + ) + } yield OffsetDateTime.of(year, month, day, hour, minute, second, nanosecond, offset) + + given Arbitrary[OffsetDateTime] = Arbitrary(genOffsetDateTime) + + val genProjectOwnerId: Gen[ProjectOwnerId] = Gen.delay(ProjectOwnerId.randomProjectOwnerId) + + val genUUID: Gen[UUID] = Gen.delay(UUID.randomUUID) + + val genValidEmailAddress: Gen[EmailAddress] = + for { + length <- Gen.choose(4, 64) + chars <- Gen.nonEmptyListOf(Gen.alphaNumChar) + email = chars.take(length).mkString + } yield EmailAddress(email + "@example.com") + + val genValidProjectOwnerName: Gen[ProjectOwnerName] = for { + length <- Gen.choose(2, 30) + prefix <- Gen.alphaChar + chars <- Gen + .nonEmptyListOf(Gen.alphaNumChar) + .map(_.take(length).mkString.toLowerCase(Locale.ROOT)) + } yield ProjectOwnerName(prefix.toString.toLowerCase(Locale.ROOT) + chars) + + val genValidProjectOwner: Gen[ProjectOwner] = for { + id <- genProjectOwnerId + name <- genValidProjectOwnerName + email <- genValidEmailAddress + } yield ProjectOwner(uid = id, name = name, email = email) + + given Arbitrary[ProjectOwner] = Arbitrary(genValidProjectOwner) + + val genValidProjectOwners: Gen[List[ProjectOwner]] = Gen + .nonEmptyListOf(genValidProjectOwner) + .suchThat(owners => owners.size === owners.map(_.name).distinct.size) // Ensure unique names. + + val genLabelName: Gen[LabelName] = + Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(LabelName.MaxLength).mkString).map(LabelName.apply) + + val genLabelDescription: Gen[LabelDescription] = + Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(LabelDescription.MaxLength).mkString).map(LabelDescription.apply) + + val genColourCode: Gen[ColourCode] = for { + red <- Gen.choose(0, 255).map(_.toHexString).map(hexPadding) + green <- Gen.choose(0, 255).map(_.toHexString).map(hexPadding) + blue <- Gen.choose(0, 255).map(_.toHexString).map(hexPadding) + hexString = s"#$red$green$blue" + } yield ColourCode(hexString) + + val genLabel: Gen[Label] = for { + id <- Gen.option(Gen.choose(0L, Long.MaxValue).map(LabelId.apply)) + name <- genLabelName + description <- Gen.option(genLabelDescription) + colour <- genColourCode + } yield Label(id, name, description, colour) + + val genLabels: Gen[List[Label]] = Gen.nonEmptyListOf(genLabel).map(_.distinct) + + val genMilestoneTitle: Gen[MilestoneTitle] = + Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(MilestoneTitle.MaxLength).mkString).map(MilestoneTitle.apply) + + val genMilestoneDescription: Gen[MilestoneDescription] = + Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.mkString).map(MilestoneDescription.apply) + + val genMilestone: Gen[Milestone] = + for { + id <- Gen.option(Gen.choose(0L, Long.MaxValue).map(MilestoneId.apply)) + title <- genMilestoneTitle + due <- Gen.option(genLocalDate) + descr <- Gen.option(genMilestoneDescription) + } yield Milestone(id, title, descr, due) + + val genMilestones: Gen[List[Milestone]] = Gen.nonEmptyListOf(genMilestone).map(_.distinct) + + val genValidProjectName: Gen[ProjectName] = Gen + .nonEmptyListOf( + Gen.oneOf( + List( + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "-", + "_" + ) + ) + ) + .map(cs => ProjectName(cs.take(64).mkString)) + + val genProjectDescription: Gen[Option[ProjectDescription]] = + Gen.alphaNumStr.map(_.take(ProjectDescription.MaximumLength)).map(ProjectDescription.from) + + val genValidProject: Gen[Project] = + for { + name <- genValidProjectName + description <- genProjectDescription + owner <- genValidProjectOwner + isPrivate <- Gen.oneOf(List(false, true)) + } yield Project(owner, name, description, isPrivate) + + val genValidProjects: Gen[List[Project]] = Gen.nonEmptyListOf(genValidProject) + +} diff -rN -u old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V1__create_schema.sql new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V1__create_schema.sql --- old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V1__create_schema.sql 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V1__create_schema.sql 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,3 @@ +CREATE SCHEMA IF NOT EXISTS "tickets"; + +COMMENT ON SCHEMA "tickets" IS 'Data related to ticket tracking.'; diff -rN -u old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V2__base_tables.sql new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V2__base_tables.sql --- old-smederee/modules/tickets/src/main/resources/db/migration/tickets/V2__base_tables.sql 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/main/resources/db/migration/tickets/V2__base_tables.sql 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,200 @@ +CREATE TABLE "tickets"."users" +( + "uid" UUID NOT NULL, + "name" CHARACTER VARYING(32) NOT NULL, + "email" CHARACTER VARYING(128) NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT "users_pk" PRIMARY KEY ("uid"), + CONSTRAINT "users_unique_name" UNIQUE ("name"), + CONSTRAINT "users_unique_email" UNIQUE ("email") +) +WITH ( + OIDS=FALSE +); + +COMMENT ON TABLE "tickets"."users" IS 'All users for the ticket system live within this table.'; +COMMENT ON COLUMN "tickets"."users"."uid" IS 'A globally unique ID for the related user account. It must match the user ID from the hub account.'; +COMMENT ON COLUMN "tickets"."users"."name" IS 'A username between 2 and 32 characters which must be globally unique, contain only lowercase alphanumeric characters and start with a character.'; +COMMENT ON COLUMN "tickets"."users"."email" IS 'A globally unique email address associated with the account.'; +COMMENT ON COLUMN "tickets"."users"."created_at" IS 'The timestamp of when the account was created.'; +COMMENT ON COLUMN "tickets"."users"."updated_at" IS 'A timestamp when the account was last changed.'; + +CREATE TABLE "tickets"."sessions" +( + "id" VARCHAR(32) NOT NULL, + "uid" UUID NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT "sessions_pk" PRIMARY KEY ("id"), + CONSTRAINT "sessions_fk_uid" FOREIGN KEY ("uid") + REFERENCES "tickets"."users" ("uid") ON UPDATE CASCADE ON DELETE CASCADE +) +WITH ( + OIDS=FALSE +); + +COMMENT ON TABLE "tickets"."sessions" IS 'Keeps the sessions of users.'; +COMMENT ON COLUMN "tickets"."sessions"."id" IS 'A globally unique session ID.'; +COMMENT ON COLUMN "tickets"."sessions"."uid" IS 'The unique ID of the user account to whom the session belongs.'; +COMMENT ON COLUMN "tickets"."sessions"."created_at" IS 'The timestamp of when the session was created.'; +COMMENT ON COLUMN "tickets"."sessions"."updated_at" IS 'The session ID should be re-generated in regular intervals resulting in a copy of the old session entry with a new ID and the corresponding timestamp in this column.'; + +CREATE TABLE "tickets"."projects" +( + "id" BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + "name" CHARACTER VARYING(64) NOT NULL, + "owner" UUID NOT NULL, + "is_private" BOOLEAN NOT NULL DEFAULT FALSE, + "description" CHARACTER VARYING(254), + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "next_ticket_number" INTEGER NOT NULL DEFAULT 1, + CONSTRAINT "projects_unique_owner_name" UNIQUE ("owner", "name"), + CONSTRAINT "projects_fk_uid" FOREIGN KEY ("owner") + REFERENCES "tickets"."users" ("uid") ON UPDATE CASCADE ON DELETE CASCADE +) +WITH ( + OIDS=FALSE +); + +COMMENT ON TABLE "tickets"."projects" IS 'All projects which are basically mirrored repositories from the hub are stored within this table.'; +COMMENT ON COLUMN "tickets"."projects"."id" IS 'An auto generated primary key.'; +COMMENT ON COLUMN "tickets"."projects"."name" IS 'The name of the project. A project name must start with a letter or number and must contain only alphanumeric ASCII characters as well as minus or underscore signs. It must be between 2 and 64 characters long.'; +COMMENT ON COLUMN "tickets"."projects"."owner" IS 'The unique ID of the user account that owns the project.'; +COMMENT ON COLUMN "tickets"."projects"."is_private" IS 'A flag indicating if this project is private i.e. only visible / accessible for users with appropriate permissions.'; +COMMENT ON COLUMN "tickets"."projects"."description" IS 'An optional short text description of the project.'; +COMMENT ON COLUMN "tickets"."projects"."created_at" IS 'The timestamp of when the project was created.'; +COMMENT ON COLUMN "tickets"."projects"."updated_at" IS 'A timestamp when the project was last changed.'; +COMMENT ON COLUMN "tickets"."projects"."next_ticket_number" IS 'Tickets are numbered ascending per project and this field holds the next logical ticket number to be used and must be incremented upon creation of a new ticket.'; + +CREATE TABLE "tickets"."labels" +( + "id" BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + "project" BIGINT NOT NULL, + "name" CHARACTER VARYING(40) NOT NULL, + "description" CHARACTER VARYING(254) DEFAULT NULL, + "colour" CHARACTER VARYING(7) NOT NULL, + CONSTRAINT "labels_unique_project_label" UNIQUE ("project", "name"), + CONSTRAINT "labels_fk_project" FOREIGN KEY ("project") + REFERENCES "tickets"."projects" ("id") ON UPDATE CASCADE ON DELETE CASCADE +) +WITH ( + OIDS=FALSE +); + +COMMENT ON TABLE "tickets"."labels" IS 'Labels used to add information to tickets.'; +COMMENT ON COLUMN "tickets"."labels"."id" IS 'An auto generated primary key.'; +COMMENT ON COLUMN "tickets"."labels"."project" IS 'The project to which this label belongs.'; +COMMENT ON COLUMN "tickets"."labels"."name" IS 'A short descriptive name for the label which is supposed to be unique in a project context.'; +COMMENT ON COLUMN "tickets"."labels"."description" IS 'An optional description if needed.'; +COMMENT ON COLUMN "tickets"."labels"."colour" IS 'A hexadecimal HTML colour code which can be used to mark the label on a rendered website.'; + +CREATE TABLE "tickets"."milestones" +( + "id" BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + "project" BIGINT NOT NULL, + "title" CHARACTER VARYING(64) NOT NULL, + "due_date" DATE DEFAULT NULL, + "description" TEXT DEFAULT NULL, + CONSTRAINT "milestones_unique_project_title" UNIQUE ("project", "title"), + CONSTRAINT "milestones_fk_project" FOREIGN KEY ("project") + REFERENCES "tickets"."projects" ("id") ON UPDATE CASCADE ON DELETE CASCADE +) +WITH ( + OIDS=FALSE +); + +COMMENT ON TABLE "tickets"."milestones" IS 'Milestones used to organise tickets'; +COMMENT ON COLUMN "tickets"."milestones"."project" IS 'The project to which this milestone belongs.'; +COMMENT ON COLUMN "tickets"."milestones"."title" IS 'A title for the milestone, usually a version number, a word or a short phrase that is supposed to be unique within a project context.'; +COMMENT ON COLUMN "tickets"."milestones"."due_date" IS 'An optional date on which the milestone is supposed to be reached.'; +COMMENT ON COLUMN "tickets"."milestones"."description" IS 'An optional longer description of the milestone.'; + +CREATE TABLE "tickets"."tickets" +( + "id" BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + "project" BIGINT NOT NULL, + "number" INT NOT NULL, + "title" CHARACTER VARYING(72) NOT NULL, + "content" TEXT DEFAULT NULL, + "status" CHARACTER VARYING(16) NOT NULL, + "submitter" UUID DEFAULT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT "tickets_unique_project_ticket" UNIQUE ("project", "number"), + CONSTRAINT "tickets_fk_project" FOREIGN KEY ("project") + REFERENCES "tickets"."projects" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "tickets_fk_submitter" FOREIGN KEY ("submitter") + REFERENCES "tickets"."users" ("uid") ON UPDATE CASCADE ON DELETE SET NULL +) +WITH ( + OIDS=FALSE +); + +CREATE INDEX "tickets_status" ON "tickets"."tickets" ("status"); + +COMMENT ON TABLE "tickets"."tickets" IS 'Information about tickets for projects.'; +COMMENT ON COLUMN "tickets"."tickets"."id" IS 'An auto generated primary key.'; +COMMENT ON COLUMN "tickets"."tickets"."project" IS 'The unique ID of the project which is associated with the ticket.'; +COMMENT ON COLUMN "tickets"."tickets"."number" IS 'The number of the ticket which must be unique within the scope of the project.'; +COMMENT ON COLUMN "tickets"."tickets"."title" IS 'A concise and short description of the ticket which should not exceed 72 characters.'; +COMMENT ON COLUMN "tickets"."tickets"."content" IS 'An optional field to describe the ticket in great detail if needed.'; +COMMENT ON COLUMN "tickets"."tickets"."status" IS 'The current status of the ticket describing its life cycle.'; +COMMENT ON COLUMN "tickets"."tickets"."submitter" IS 'The person who submitted (created) this ticket which is optional because of possible account deletion or other reasons.'; +COMMENT ON COLUMN "tickets"."tickets"."created_at" IS 'The timestamp when the ticket was created / submitted.'; +COMMENT ON COLUMN "tickets"."tickets"."updated_at" IS 'The timestamp when the ticket was last updated. Upon creation the update time equals the creation time.'; + +CREATE TABLE "tickets"."milestone_tickets" +( + "milestone" BIGINT NOT NULL, + "ticket" BIGINT NOT NULL, + CONSTRAINT "milestone_tickets_pk" PRIMARY KEY ("milestone", "ticket"), + CONSTRAINT "milestone_tickets_fk_milestone" FOREIGN KEY ("milestone") + REFERENCES "tickets"."milestones" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "milestone_tickets_fk_ticket" FOREIGN KEY ("ticket") + REFERENCES "tickets"."tickets" ("id") ON UPDATE CASCADE ON DELETE CASCADE +) +WITH ( + OIDS=FALSE +); + +COMMENT ON TABLE "tickets"."milestone_tickets" IS 'This table stores the relation between milestones and their tickets.'; +COMMENT ON COLUMN "tickets"."milestone_tickets"."milestone" IS 'The unique ID of the milestone.'; +COMMENT ON COLUMN "tickets"."milestone_tickets"."ticket" IS 'The unique ID of the ticket that is attached to the milestone.'; + +CREATE TABLE "tickets"."ticket_assignees" +( + "ticket" BIGINT NOT NULL, + "assignee" UUID NOT NULL, + CONSTRAINT "ticket_assignees_pk" PRIMARY KEY ("ticket", "assignee"), + CONSTRAINT "ticket_assignees_fk_ticket" FOREIGN KEY ("ticket") + REFERENCES "tickets"."tickets" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "ticket_assignees_fk_assignee" FOREIGN KEY ("assignee") + REFERENCES "tickets"."users" ("uid") ON UPDATE CASCADE ON DELETE CASCADE +) +WITH ( + OIDS=FALSE +); + +COMMENT ON TABLE "tickets"."ticket_assignees" IS 'This table stores the relation between tickets and their assignees.'; +COMMENT ON COLUMN "tickets"."ticket_assignees"."ticket" IS 'The unqiue ID of the ticket.'; +COMMENT ON COLUMN "tickets"."ticket_assignees"."assignee" IS 'The unique ID of the user account that is assigned to the ticket.'; + +CREATE TABLE "tickets"."ticket_labels" +( + "ticket" BIGINT NOT NULL, + "label" BIGINT NOT NULL, + CONSTRAINT "ticket_labels_pk" PRIMARY KEY ("ticket", "label"), + CONSTRAINT "ticket_labels_fk_ticket" FOREIGN KEY ("ticket") + REFERENCES "tickets"."tickets" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "ticket_labels_fk_label" FOREIGN KEY ("label") + REFERENCES "tickets"."labels" ("id") ON UPDATE CASCADE ON DELETE CASCADE +) +WITH ( + OIDS=FALSE +); + +COMMENT ON TABLE "tickets"."ticket_labels" IS 'This table stores the relation between tickets and their labels.'; +COMMENT ON COLUMN "tickets"."ticket_labels"."ticket" IS 'The unqiue ID of the ticket.'; +COMMENT ON COLUMN "tickets"."ticket_labels"."label" IS 'The unique ID of the label that is attached to the ticket.'; diff -rN -u old-smederee/modules/tickets/src/main/resources/logback.xml new-smederee/modules/tickets/src/main/resources/logback.xml --- old-smederee/modules/tickets/src/main/resources/logback.xml 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/main/resources/logback.xml 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<configuration debug="false"> + <appender name="console" class="ch.qos.logback.core.ConsoleAppender"> + <encoder> + <pattern>%date %highlight(%-5level) %cyan(%logger{0}) - %msg%n</pattern> + </encoder> + </appender> + + <appender name="async-console" class="ch.qos.logback.classic.AsyncAppender"> + <appender-ref ref="console"/> + <queueSize>5000</queueSize> + <discardingThreshold>0</discardingThreshold> + </appender> + + <logger name="de.smederee.tickets" level="${smederee.tickets.loglevel:-INFO}" additivity="false"> + <appender-ref ref="async-console"/> + </logger> + + <root level="INFO"> + <appender-ref ref="async-console"/> + </root> +</configuration> diff -rN -u old-smederee/modules/tickets/src/main/resources/reference.conf new-smederee/modules/tickets/src/main/resources/reference.conf --- old-smederee/modules/tickets/src/main/resources/reference.conf 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/main/resources/reference.conf 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,107 @@ +############################################################################### +### Reference configuration file for the Smederee tickets service. ### +############################################################################### + +tickets { + # Authentication / login settings + authentication { + enabled = true + + # The name used for the authentication cookie. + cookie-name = "sloetel" + + # The secret used for the cookie encryption and validation. + # Using the default should produce a warning message on startup. + cookie-secret = "CHANGEME" + + # Determines after how many failed login attempts an account gets locked. + lock-after = 5 + + # Timeouts for the authentication session. + timeouts { + # The maximum allowed age an authentication session. This setting will + # affect the invalidation of a session on the server side. + # This timeout MUST be triggered regardless of session activity. + absolute-timeout = 3 days + + # This timeout defines how long after the last activity a session will + # remain valid. + idle-timeout = 30 minutes + + # The time after which a session will be renewed (a new session ID will be + # generated). + renewal-timeout = 20 minutes + } + } + + # Configuration of the CSRF protection middleware. + csrf-protection { + # The official hostname of the service which will be used for the CSRF + # protection. + host = ${tickets.service.host} + + # The port number which defaults to the port the service is listening on. + # If the service is running behind a reverse proxy on a standard port e.g. + # 80 or 443 (http or https) then you MUST set this either to `port = null` + # or comment it out! + port = ${tickets.service.port} + + # The URL scheme which is used for links and will also determine if cookies + # will have the secure flag enabled. + # Valid options are: + # - http + # - https + scheme = "http" + } + + # Configuration of the database. + # Defaults are given except for password and can also be overridden via + # environment variables. + database { + # The class name of the JDBC driver to be used. + driver = "org.postgresql.Driver" + driver = ${?SMEDEREE_TICKETS_DB_DRIVER} + # The JDBC connection URL **without** username and password. + url = "jdbc:postgresql://localhost:5432/smederee" + url = ${?SMEDEREE_TICKETS_DB_URL} + # The username (login) needed to authenticate against the database. + user = "smederee_tickets" + user = ${?SMEDEREE_TICKETS_DB_USER} + # The password needed to authenticate against the database. + pass = ${?SMEDEREE_TICKETS_DB_PASS} + } + + # Settings affecting how the service will communicate several information to + # the "outside world" e.g. if it runs behind a reverse proxy. + external-url { + # The official hostname of the service which will be used for the generation + # of links. + host = ${tickets.service.host} + + # A possible path prefix that will be prepended to any paths used in link + # generation. If no path prefix is used then you MUST either comment it out + # or set it to `path = null`! + #path = null + + # The port number which defaults to the port the service is listening on. + # If the service is running behind a reverse proxy on a standard port e.g. + # 80 or 443 (http or https) then you MUST set this either to `port = null` + # or comment it out! + port = ${tickets.service.port} + + # The URL scheme which is used for links and will also determine if cookies + # will have the secure flag enabled. + # Valid options are: + # - http + # - https + scheme = "http" + } + + # Generic service configuration. + service { + # The hostname on which the service shall listen for requests. + host = "localhost" + # The TCP port number on which the service shall listen for requests. + port = 8081 + } +} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Assignee.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Assignee.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Assignee.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Assignee.scala 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,163 @@ +/* + * 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.tickets + +import java.util.UUID + +import cats._ +import cats.data._ +import cats.syntax.all._ + +/** A submitter id is supposed to be globally unique and maps to the [[java.util.UUID]] type beneath. + */ +opaque type AssigneeId = UUID +object AssigneeId { + val Format = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$".r + + given Eq[AssigneeId] = Eq.fromUniversalEquals + + /** Create an instance of AssigneeId from the given UUID type. + * + * @param source + * An instance of type UUID which will be returned as a AssigneeId. + * @return + * The appropriate instance of AssigneeId. + */ + def apply(source: UUID): AssigneeId = source + + /** Try to create an instance of AssigneeId from the given UUID. + * + * @param source + * A UUID that should fulfil the requirements to be converted into a AssigneeId. + * @return + * An option to the successfully converted AssigneeId. + */ + def from(source: UUID): Option[AssigneeId] = Option(source) + + /** Try to create an instance of AssigneeId from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a AssigneeId. + * @return + * An option to the successfully converted AssigneeId. + */ + def fromString(source: String): Either[String, AssigneeId] = + Option(source) + .filter(s => Format.matches(s)) + .flatMap { uuidString => + Either.catchNonFatal(UUID.fromString(uuidString)).toOption + } + .toRight("Illegal value for AssigneeId!") + + /** Generate a new random user id. + * + * @return + * A user id which is pseudo randomly generated. + */ + def randomAssigneeId: AssigneeId = UUID.randomUUID + + extension (uid: AssigneeId) { + def toUUID: UUID = uid + } +} + +/** A submitter name has to obey several restrictions which are similiar to the ones found for Unix usernames. It must + * start with a letter, contain only alphanumeric characters, be at least 2 and at most 31 characters long and be all + * lowercase. + */ +opaque type AssigneeName = String +object AssigneeName { + given Eq[AssigneeName] = Eq.fromUniversalEquals + + val isAlphanumeric = "^[a-z][a-z0-9]+$".r + + /** Create an instance of AssigneeName from the given String type. + * + * @param source + * An instance of type String which will be returned as a AssigneeName. + * @return + * The appropriate instance of AssigneeName. + */ + def apply(source: String): AssigneeName = source + + /** Try to create an instance of AssigneeName from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a AssigneeName. + * @return + * An option to the successfully converted AssigneeName. + */ + def from(s: String): Option[AssigneeName] = validate(s).toOption + + /** Validate the given string and return either the validated username or a list of errors. + * + * @param s + * An arbitrary string which should be a username. + * @return + * Either a list of errors or the validated username. + */ + def validate(s: String): ValidatedNec[String, AssigneeName] = + Option(s).map(_.trim.nonEmpty) match { + case Some(true) => + val input = s.trim + val miniumLength = + if (input.length >= 2) + input.validNec + else + "AssigneeName too short (min. 2 characters)!".invalidNec + val maximumLength = + if (input.length < 32) + input.validNec + else + "AssigneeName too long (max. 31 characters)!".invalidNec + val alphanumeric = + if (isAlphanumeric.matches(input)) + input.validNec + else + "AssigneeName must be all lowercase alphanumeric characters and start with a character.".invalidNec + (miniumLength, maximumLength, alphanumeric).mapN { case (_, _, name) => + name + } + case _ => "AssigneeName must not be empty!".invalidNec + } +} + +/** Extractor to retrieve an AssigneeName from a path parameter. + */ +object AssigneeNamePathParameter { + def unapply(str: String): Option[AssigneeName] = + Option(str).flatMap { string => + if (string.startsWith("~")) + AssigneeName.from(string.drop(1)) + else + None + } +} + +/** The assignee for a ticket i.e. the person supposed to be working on it. + * + * @param id + * A globally unique ID identifying the assignee. + * @param name + * The name associated with the assignee which is supposed to be unique. + */ +final case class Assignee(id: AssigneeId, name: AssigneeName) + +object Assignee { + given Eq[Assignee] = Eq.fromUniversalEquals +} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/ConfigurationPath.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/ConfigurationPath.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/ConfigurationPath.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/ConfigurationPath.scala 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,45 @@ +/* + * 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.tickets.config + +/** A configuration path describes a path within a configuration file and is used to determine locations of certain + * configurations within a combined configuration file. + */ +opaque type ConfigurationPath = String +object ConfigurationPath { + + given Conversion[ConfigurationPath, String] = _.toString + + /** Create an instance of ConfigurationPath from the given String type. + * + * @param source + * An instance of type String which will be returned as a ConfigurationPath. + * @return + * The appropriate instance of ConfigurationPath. + */ + def apply(source: String): ConfigurationPath = source + + /** Try to create an instance of ConfigurationPath from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a ConfigurationPath. + * @return + * An option to the successfully converted ConfigurationPath. + */ + def from(source: String): Option[ConfigurationPath] = Option(source).map(_.trim).filter(_.nonEmpty) +} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseConfig.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseConfig.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseConfig.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseConfig.scala 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,37 @@ +/* + * 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.tickets.config + +import pureconfig._ + +/** Configuration specifying the database access. + * + * @param driver + * The class name of the JDBC driver to be used. + * @param url + * The JDBC connection URL **without** username and password. + * @param user + * The username (login) needed to authenticate against the database. + * @param pass + * The password needed to authenticate against the database. + */ +final case class DatabaseConfig(driver: String, url: String, user: String, pass: String) + +object DatabaseConfig { + given ConfigReader[DatabaseConfig] = ConfigReader.forProduct4("driver", "url", "user", "pass")(DatabaseConfig.apply) +} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseMigrator.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseMigrator.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseMigrator.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/DatabaseMigrator.scala 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,66 @@ +/* + * 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.tickets.config + +import cats.effect._ +import cats.syntax.all._ +import org.flywaydb.core.Flyway +import org.flywaydb.core.api.configuration.FluentConfiguration +import org.flywaydb.core.api.output.MigrateResult + +/** Provide functionality to migrate the database used by the service. + */ +final class DatabaseMigrator[F[_]: Sync] { + + /** Apply pending migrations to the database if needed using the underlying Flyway library. + * + * @param url + * The JDBC connection URL **without** username and password. + * @param user + * The username (login) needed to authenticate against the database. + * @param pass + * The password needed to authenticate against the database. + * @return + * A migrate result object holding information about executed migrations and the schema. See the Java-Doc of Flyway + * for details. + */ + def migrate(url: String, user: String, pass: String): F[MigrateResult] = + for { + flyway <- Sync[F].delay(DatabaseMigrator.configureFlyway(url, user, pass).load()) + result <- Sync[F].delay(flyway.migrate()) + } yield result +} + +object DatabaseMigrator { + + /** Configure an instance of flyway with our defaults and the given credentials for a database connection. The + * returned instance must be activated by calling the `.load()` method. + * + * @param url + * The JDBC connection URL **without** username and password. + * @param user + * The username (login) needed to authenticate against the database. + * @param pass + * The password needed to authenticate against the database. + * @return + * An instance configuration which can be turned into a flyway database migrator by calling the `.load()` method. + */ + def configureFlyway(url: String, user: String, pass: String): FluentConfiguration = + Flyway.configure().defaultSchema("tickets").locations("classpath:db/migration/tickets").dataSource(url, user, pass) + +} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/config/SmedereeTicketsConfiguration.scala 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,77 @@ +/* + * 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.tickets.config + +import cats._ +import com.comcast.ip4s._ +import de.smederee.html.ExternalUrlConfiguration +import org.http4s.Uri +import pureconfig._ + +/** Configuration for a CSRF protection middleware. + * + * @param host + * The hostname which will be expected and must be matched. + * @param port + * An optional portnumber which will be expected if set. + * @param scheme + * The URL scheme which is either HTTP or HTTPS. + */ +final case class CsrfProtectionConfiguration(host: Host, port: Option[Port], scheme: Uri.Scheme) + +object CsrfProtectionConfiguration { + given Eq[CsrfProtectionConfiguration] = Eq.fromUniversalEquals + + given ConfigReader[Host] = ConfigReader.fromStringOpt[Host](Host.fromString) + given ConfigReader[Port] = ConfigReader.fromStringOpt[Port](Port.fromString) + given ConfigReader[Uri.Scheme] = ConfigReader.fromStringOpt(s => Uri.Scheme.fromString(s).toOption) + + given ConfigReader[CsrfProtectionConfiguration] = + ConfigReader.forProduct3("host", "port", "scheme")(CsrfProtectionConfiguration.apply) +} + +/** Wrapper class for the confiuration of the Smederee tickets module. + * + * @param csrfProtection + * The CSRF protection configuration. + * @param database + * The configuration needed to access the database. + * @param externalUrl + * Configuration regarding support for generating "external urls" which is usually needed if the service runs behind + * a reverse proxy. + */ +final case class SmedereeTicketsConfiguration( + csrfProtection: CsrfProtectionConfiguration, + database: DatabaseConfig, + externalUrl: ExternalUrlConfiguration +) + +object SmedereeTicketsConfiguration { + val location: ConfigurationPath = ConfigurationPath("tickets") + + given ConfigReader[Host] = ConfigReader.fromStringOpt[Host](Host.fromString) + given ConfigReader[Port] = ConfigReader.fromStringOpt[Port](Port.fromString) + given ConfigReader[Uri] = ConfigReader.fromStringOpt(s => Uri.fromString(s).toOption) + given ConfigReader[Uri.Scheme] = ConfigReader.fromStringOpt(s => Uri.Scheme.fromString(s).toOption) + + given ConfigReader[ExternalUrlConfiguration] = + ConfigReader.forProduct4("host", "path", "port", "scheme")(ExternalUrlConfiguration.apply) + + given ConfigReader[SmedereeTicketsConfiguration] = + ConfigReader.forProduct3("csrf-protection", "database", "external-url")(SmedereeTicketsConfiguration.apply) +} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,76 @@ +/* + * 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.tickets + +import cats.effect._ +import doobie._ +import doobie.implicits._ +import fs2.Stream + +final class DoobieLabelRepository[F[_]: Sync](tx: Transactor[F]) extends LabelRepository[F] { + given Meta[ColourCode] = Meta[String].timap(ColourCode.apply)(_.toString) + given Meta[LabelDescription] = Meta[String].timap(LabelDescription.apply)(_.toString) + given Meta[LabelId] = Meta[Long].timap(LabelId.apply)(_.toLong) + given Meta[LabelName] = Meta[String].timap(LabelName.apply)(_.toString) + + override def allLabels(vcsRepositoryId: Long): Stream[F, Label] = + sql"""SELECT id, name, description, colour FROM "tickets"."labels" WHERE project = $vcsRepositoryId ORDER BY name ASC""" + .query[Label] + .stream + .transact(tx) + + override def createLabel(vcsRepositoryId: Long)(label: Label): F[Int] = + sql"""INSERT INTO "tickets"."labels" + ( + project, + name, + description, + colour + ) + VALUES ( + $vcsRepositoryId, + ${label.name}, + ${label.description}, + ${label.colour} + )""".update.run.transact(tx) + + override def deleteLabel(label: Label): F[Int] = + label.id match { + case None => Sync[F].pure(0) + case Some(id) => + sql"""DELETE FROM "tickets"."labels" WHERE id = $id""".update.run.transact(tx) + } + + override def findLabel(vcsRepositoryId: Long)(name: LabelName): F[Option[Label]] = + sql"""SELECT id, name, description, colour FROM "tickets"."labels" WHERE project = $vcsRepositoryId AND name = $name LIMIT 1""" + .query[Label] + .option + .transact(tx) + + override def updateLabel(label: Label): F[Int] = + label.id match { + case None => Sync[F].pure(0) + case Some(id) => + sql"""UPDATE "tickets"."labels" + SET name = ${label.name}, + description = ${label.description}, + colour = ${label.colour} + WHERE id = $id""".update.run.transact(tx) + } + +} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,75 @@ +/* + * 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.tickets + +import cats.effect._ +import doobie._ +import doobie.implicits._ +import doobie.postgres.implicits._ +import fs2.Stream + +final class DoobieMilestoneRepository[F[_]: Sync](tx: Transactor[F]) extends MilestoneRepository[F] { + given Meta[MilestoneDescription] = Meta[String].timap(MilestoneDescription.apply)(_.toString) + given Meta[MilestoneId] = Meta[Long].timap(MilestoneId.apply)(_.toLong) + given Meta[MilestoneTitle] = Meta[String].timap(MilestoneTitle.apply)(_.toString) + + override def allMilestones(projectId: Long): Stream[F, Milestone] = + sql"""SELECT id, title, description, due_date FROM "tickets"."milestones" WHERE project = $projectId ORDER BY due_date ASC, title ASC""" + .query[Milestone] + .stream + .transact(tx) + + override def createMilestone(projectId: Long)(milestone: Milestone): F[Int] = + sql"""INSERT INTO "tickets"."milestones" + ( + project, + title, + due_date, + description + ) + VALUES ( + $projectId, + ${milestone.title}, + ${milestone.dueDate}, + ${milestone.description} + )""".update.run.transact(tx) + + override def deleteMilestone(milestone: Milestone): F[Int] = + milestone.id match { + case None => Sync[F].pure(0) + case Some(id) => sql"""DELETE FROM "tickets"."milestones" WHERE id = $id""".update.run.transact(tx) + } + + override def findMilestone(projectId: Long)(title: MilestoneTitle): F[Option[Milestone]] = + sql"""SELECT id, title, description, due_date FROM "tickets"."milestones" WHERE project = $projectId AND title = $title LIMIT 1""" + .query[Milestone] + .option + .transact(tx) + + override def updateMilestone(milestone: Milestone): F[Int] = + milestone.id match { + case None => Sync[F].pure(0) + case Some(id) => + sql"""UPDATE "tickets"."milestones" + SET title = ${milestone.title}, + due_date = ${milestone.dueDate}, + description = ${milestone.description} + WHERE id = $id""".update.run.transact(tx) + } + +} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/FormValidator.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/FormValidator.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/FormValidator.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/FormValidator.scala 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,54 @@ +/* + * 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.tickets.forms + +import cats.data._ +import de.smederee.tickets.forms.types._ + +/** A base class for form validators. + * + * <p>It is intended to extend this class if you want to provide a more sophisticated validation for a form which gets + * submitted as raw stringified map.</p> + * + * <p>Please note that you can achieve auto validation if you use proper models (with refined types) in your tapir + * endpoints.</p> + * + * <p>However, sometimes you want to have more fine grained control...</p> + * + * @tparam T + * The concrete type of the validated form output. + */ +abstract class FormValidator[T] { + final val fieldGlobal: FormField = FormValidator.fieldGlobal + + /** Validate the submitted form data which is received as stringified map and return either a validated `T` or a list + * of [[de.smederee.tickets.forms.types.FormErrors]]. + * + * @param data + * The stringified map which was submitted. + * @return + * Either the validated form as concrete type T or a list of form errors. + */ + def validate(data: Map[String, String]): ValidatedNec[FormErrors, T] + +} + +object FormValidator { + // A constant for the field name used for global errors. + val fieldGlobal: FormField = FormField("global") +} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/types.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/types.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/types.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/types.scala 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,106 @@ +/* + * 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.tickets.forms + +import cats.data._ +import cats.syntax.all._ + +object types { + + type FormErrors = Map[FormField, List[FormFieldError]] + object FormErrors { + val empty: FormErrors = Map.empty[FormField, List[FormFieldError]] + + /** Create a single FormErrors instance from a given non empty chain of FormErrors which is usually returned from + * validators. + * + * @param errors + * A non empty chain of FormErrors. + * @return + * A single FormErrors instance containing all the errors. + */ + def fromNec(errors: NonEmptyChain[FormErrors]): FormErrors = errors.toList.fold(FormErrors.empty)(_ combine _) + + /** Create a single FormErrors instance from a given non empty list of FormErrors which is usually returned from + * validators. + * + * @param errors + * A non empty list of FormErrors. + * @return + * A single FormErrors instance containing all the errors. + */ + def fromNel(errors: NonEmptyList[FormErrors]): FormErrors = errors.toList.fold(FormErrors.empty)(_ combine _) + } + + opaque type FormField = String + object FormField { + + /** Create an instance of FormField from the given String type. + * + * @param source + * An instance of type String which will be returned as a FormField. + * @return + * The appropriate instance of FormField. + */ + def apply(source: String): FormField = source + + /** Try to create an instance of FormField from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a FormField. + * @return + * An option to the successfully converted FormField. + */ + def from(source: String): Option[FormField] = + Option(source).map(_.trim.nonEmpty) match { + case Some(true) => Option(source.trim) + case _ => None + } + } + + given Conversion[FormField, String] = _.toString + + opaque type FormFieldError = String + object FormFieldError { + + /** Create an instance of FormFieldError from the given String type. + * + * @param source + * An instance of type String which will be returned as a FormFieldError. + * @return + * The appropriate instance of FormFieldError. + */ + def apply(source: String): FormFieldError = source + + /** Try to create an instance of FormFieldError from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a FormFieldError. + * @return + * An option to the successfully converted FormFieldError. + */ + def from(source: String): Option[FormFieldError] = + Option(source).map(_.trim.nonEmpty) match { + case Some(true) => Option(source.trim) + case _ => None + } + } + + given Conversion[FormFieldError, String] = _.toString + +} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/LabelRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/LabelRepository.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/LabelRepository.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/LabelRepository.scala 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,78 @@ +/* + * 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.tickets + +import fs2.Stream + +/** The base class that defines the needed functionality to handle labels within a database. + * + * @tparam F + * A higher kinded type which wraps the actual return values. + */ +abstract class LabelRepository[F[_]] { + + /** Return all labels associated with the given repository. + * + * @param vcsRepositoryId + * The unique internal ID of a vcs repository metadata entry for which all labels shall be returned. + * @return + * A stream of labels associated with the vcs repository which may be empty. + */ + def allLabels(vcsRepositoryId: Long): Stream[F, Label] + + /** Create a database entry for the given label definition. + * + * @param vcsRepositoryId + * The unique internal ID of a vcs repository metadata entry to which the label belongs. + * @param label + * The label definition that shall be written to the database. + * @return + * The number of affected database rows. + */ + def createLabel(vcsRepositoryId: Long)(label: Label): F[Int] + + /** Delete the label from the database. + * + * @param label + * The label definition that shall be deleted from the database. + * @return + * The number of affected database rows. + */ + def deleteLabel(label: Label): F[Int] + + /** Find the label with the given name for the given vcs repository. + * + * @param vcsRepositoryId + * The unique internal ID of a vcs repository metadata entry to which the label belongs. + * @param name + * The name of the label which is must be unique in the context of the repository. + * @return + * An option to the found label. + */ + def findLabel(vcsRepositoryId: Long)(name: LabelName): F[Option[Label]] + + /** Update the database entry for the given label. + * + * @param label + * The label definition that shall be updated within the database. + * @return + * The number of affected database rows. + */ + def updateLabel(label: Label): F[Int] + +} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Label.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Label.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Label.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Label.scala 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,186 @@ +/* + * 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.tickets + +import cats._ +import cats.syntax.all._ + +import scala.util.matching.Regex + +opaque type LabelId = Long +object LabelId { + given Eq[LabelId] = Eq.fromUniversalEquals + + val Format: Regex = "^-?\\d+$".r + + /** Create an instance of LabelId from the given Long type. + * + * @param source + * An instance of type Long which will be returned as a LabelId. + * @return + * The appropriate instance of LabelId. + */ + def apply(source: Long): LabelId = source + + /** Try to create an instance of LabelId from the given Long. + * + * @param source + * A Long that should fulfil the requirements to be converted into a LabelId. + * @return + * An option to the successfully converted LabelId. + */ + def from(source: Long): Option[LabelId] = Option(source) + + /** Try to create an instance of LabelId from the given String. + * + * @param source + * A string that should fulfil the requirements to be converted into a LabelId. + * @return + * An option to the successfully converted LabelId. + */ + def fromString(source: String): Option[LabelId] = Option(source).filter(Format.matches).map(_.toLong).flatMap(from) + + extension (id: LabelId) { + def toLong: Long = id + } + +} + +/** Extractor to retrieve an LabelId from a path parameter. + */ +object LabelIdPathParameter { + def unapply(str: String): Option[LabelId] = Option(str).flatMap(LabelId.fromString) +} + +/** A short descriptive name for the label which is supposed to be unique in a project context. It must not be empty and + * not exceed 40 characters in length. + */ +opaque type LabelName = String +object LabelName { + given Eq[LabelName] = Eq.fromUniversalEquals + given Ordering[LabelName] = (x: LabelName, y: LabelName) => x.compareTo(y) + given Order[LabelName] = Order.fromOrdering[LabelName] + + val MaxLength: Int = 40 + + /** Create an instance of LabelName from the given String type. + * + * @param source + * An instance of type String which will be returned as a LabelName. + * @return + * The appropriate instance of LabelName. + */ + def apply(source: String): LabelName = source + + /** Try to create an instance of LabelName from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a LabelName. + * @return + * An option to the successfully converted LabelName. + */ + def from(source: String): Option[LabelName] = + Option(source).filter(string => string.nonEmpty && string.length <= MaxLength) + +} + +/** Extractor to retrieve an LabelName from a path parameter. + */ +object LabelNamePathParameter { + def unapply(str: String): Option[LabelName] = Option(str).flatMap(LabelName.from) +} + +/** A maybe needed description of a label which must not be empty and not exceed 254 characters in length. + */ +opaque type LabelDescription = String +object LabelDescription { + given Eq[LabelDescription] = Eq.fromUniversalEquals + + val MaxLength: Int = 254 + + /** Create an instance of LabelDescription from the given String type. + * + * @param source + * An instance of type String which will be returned as a LabelDescription. + * @return + * The appropriate instance of LabelDescription. + */ + def apply(source: String): LabelDescription = source + + /** Try to create an instance of LabelDescription from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a LabelDescription. + * @return + * An option to the successfully converted LabelDescription. + */ + def from(source: String): Option[LabelDescription] = + Option(source).filter(string => string.nonEmpty && string.length <= MaxLength) +} + +/** An HTML colour code in hexadecimal notation e.g. `#12ab3f`. It must match the format of starting with a hashtag + * followed by three 2-digit hexadecimal codes (`00-ff`). + */ +opaque type ColourCode = String +object ColourCode { + given Eq[ColourCode] = Eq.instance((a, b) => a.equalsIgnoreCase(b)) + + val Format: Regex = "^#[0-9a-fA-F]{6}$".r + + /** Create an instance of ColourCode from the given String type. + * + * @param source + * An instance of type String which will be returned as a ColourCode. + * @return + * The appropriate instance of ColourCode. + */ + def apply(source: String): ColourCode = source + + /** Try to create an instance of ColourCode from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a ColourCode. + * @return + * An option to the successfully converted ColourCode. + */ + def from(source: String): Option[ColourCode] = Option(source).filter(string => Format.matches(string)) + +} + +/** A label is intended to mark tickets with keywords and colours to allow filtering on them. + * + * @param id + * An optional attribute containing the unique internal database ID for the label. + * @param name + * A short descriptive name for the label which is supposed to be unique in a project context. + * @param description + * An optional description if needed. + * @param colour + * A hexadecimal HTML colour code which can be used to mark the label on a rendered website. + */ +final case class Label(id: Option[LabelId], name: LabelName, description: Option[LabelDescription], colour: ColourCode) + +object Label { + given Eq[Label] = + Eq.instance((thisLabel, thatLabel) => + thisLabel.id === thatLabel.id && + thisLabel.name === thatLabel.name && + thisLabel.description === thatLabel.description && + thisLabel.colour === thatLabel.colour + ) +} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/MilestoneRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/MilestoneRepository.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/MilestoneRepository.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/MilestoneRepository.scala 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,78 @@ +/* + * 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.tickets + +import fs2.Stream + +/** The base class that defines the needed functionality to handle milestones within a database. + * + * @tparam F + * A higher kinded type which wraps the actual return values. + */ +abstract class MilestoneRepository[F[_]] { + + /** Return all milestones associated with the given repository. + * + * @param vcsRepositoryId + * The unique internal ID of a vcs repository metadata entry for which all milestones shall be returned. + * @return + * A stream of milestones associated with the vcs repository which may be empty. + */ + def allMilestones(vcsRepositoryId: Long): Stream[F, Milestone] + + /** Create a database entry for the given milestone definition. + * + * @param vcsRepositoryId + * The unique internal ID of a vcs repository metadata entry to which the milestone belongs. + * @param milestone + * The milestone definition that shall be written to the database. + * @return + * The number of affected database rows. + */ + def createMilestone(vcsRepositoryId: Long)(milestone: Milestone): F[Int] + + /** Delete the milestone from the database. + * + * @param milestone + * The milestone definition that shall be deleted from the database. + * @return + * The number of affected database rows. + */ + def deleteMilestone(milestone: Milestone): F[Int] + + /** Find the milestone with the given title for the given vcs repository. + * + * @param vcsRepositoryId + * The unique internal ID of a vcs repository metadata entry to which the milestone belongs. + * @param title + * The title of the milestone which is must be unique in the context of the repository. + * @return + * An option to the found milestone. + */ + def findMilestone(vcsRepositoryId: Long)(title: MilestoneTitle): F[Option[Milestone]] + + /** Update the database entry for the given milestone. + * + * @param milestone + * The milestone definition that shall be updated within the database. + * @return + * The number of affected database rows. + */ + def updateMilestone(milestone: Milestone): F[Int] + +} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Milestone.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Milestone.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Milestone.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Milestone.scala 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,164 @@ +/* + * 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.tickets + +import java.time.LocalDate + +import cats._ +import cats.syntax.all._ + +import scala.util.matching.Regex + +opaque type MilestoneId = Long +object MilestoneId { + given Eq[MilestoneId] = Eq.fromUniversalEquals + + val Format: Regex = "^-?\\d+$".r + + /** Create an instance of MilestoneId from the given Long type. + * + * @param source + * An instance of type Long which will be returned as a MilestoneId. + * @return + * The appropriate instance of MilestoneId. + */ + def apply(source: Long): MilestoneId = source + + /** Try to create an instance of MilestoneId from the given Long. + * + * @param source + * A Long that should fulfil the requirements to be converted into a MilestoneId. + * @return + * An option to the successfully converted MilestoneId. + */ + def from(source: Long): Option[MilestoneId] = Option(source) + + /** Try to create an instance of MilestoneId from the given String. + * + * @param source + * A string that should fulfil the requirements to be converted into a MilestoneId. + * @return + * An option to the successfully converted MilestoneId. + */ + def fromString(source: String): Option[MilestoneId] = + Option(source).filter(Format.matches).map(_.toLong).flatMap(from) + + extension (id: MilestoneId) { + def toLong: Long = id + } +} + +/** Extractor to retrieve an MilestoneId from a path parameter. + */ +object MilestoneIdPathParameter { + def unapply(str: String): Option[MilestoneId] = Option(str).flatMap(MilestoneId.fromString) +} + +/** A title for a milestone, usually a version number, a word or a short phrase that is supposed to be unique within a + * project context. It must not be empty and not exceed 64 characters in length. + */ +opaque type MilestoneTitle = String +object MilestoneTitle { + given Eq[MilestoneTitle] = Eq.fromUniversalEquals + given Ordering[MilestoneTitle] = (x: MilestoneTitle, y: MilestoneTitle) => x.compareTo(y) + given Order[MilestoneTitle] = Order.fromOrdering[MilestoneTitle] + + val MaxLength: Int = 64 + + /** Create an instance of MilestoneTitle from the given String type. + * + * @param source + * An instance of type String which will be returned as a MilestoneTitle. + * @return + * The appropriate instance of MilestoneTitle. + */ + def apply(source: String): MilestoneTitle = source + + /** Try to create an instance of MilestoneTitle from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a MilestoneTitle. + * @return + * An option to the successfully converted MilestoneTitle. + */ + def from(source: String): Option[MilestoneTitle] = + Option(source).filter(string => string.nonEmpty && string.length <= MaxLength) + +} + +/** Extractor to retrieve an MilestoneTitle from a path parameter. + */ +object MilestoneTitlePathParameter { + def unapply(str: String): Option[MilestoneTitle] = Option(str).flatMap(MilestoneTitle.from) +} + +/** A longer detailed description of a project milestone which must not be empty. + */ +opaque type MilestoneDescription = String +object MilestoneDescription { + given Eq[MilestoneDescription] = Eq.fromUniversalEquals + + /** Create an instance of MilestoneDescription from the given String type. + * + * @param source + * An instance of type String which will be returned as a MilestoneDescription. + * @return + * The appropriate instance of MilestoneDescription. + */ + def apply(source: String): MilestoneDescription = source + + /** Try to create an instance of MilestoneDescription from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a MilestoneDescription. + * @return + * An option to the successfully converted MilestoneDescription. + */ + def from(source: String): Option[MilestoneDescription] = Option(source).map(_.trim).filter(_.nonEmpty) + +} + +/** A milestone can be used to organise tickets and progress inside a project. + * + * @param id + * An optional attribute containing the unique internal database ID for the milestone. + * @param title + * A title for the milestone, usually a version number, a word or a short phrase that is supposed to be unique within + * a project context. + * @param description + * An optional longer description of the milestone. + * @param dueDate + * An optional date on which the milestone is supposed to be reached. + */ +final case class Milestone( + id: Option[MilestoneId], + title: MilestoneTitle, + description: Option[MilestoneDescription], + dueDate: Option[LocalDate] +) + +object Milestone { + + given Eq[LocalDate] = Eq.instance((a, b) => a.compareTo(b) === 0) + + given Eq[Milestone] = + Eq.instance((a, b) => + a.id === b.id && a.title === b.title && a.dueDate === b.dueDate && a.description === b.description + ) + +} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,58 @@ +/* + * 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.tickets + +/** A base class for a database repository that should handle all functionality regarding projects in the database. + * + * @tparam F + * A higher kinded type which wraps the actual return values. + */ +abstract class ProjectRepository[F[_]] { + + /** Search for the project entry with the given owner and name. + * + * @param owner + * Data about the owner of the project containing information needed to query the database. + * @param name + * The project name which must be unique in regard to the owner. + * @return + * An option to the successfully found project entry. + */ + def findProject(owner: ProjectOwner, name: ProjectName): F[Option[Project]] + + /** Search for the internal database specific (auto generated) ID of the given owner / project combination which + * serves as a primary key for the database table. + * + * @param owner + * Data about the owner of the project containing information needed to query the database. + * @param name + * The project name which must be unique in regard to the owner. + * @return + * An option to the internal database ID. + */ + def findProjectId(owner: ProjectOwner, name: ProjectName): F[Option[Long]] + + /** Search for a project owner of whom we only know the name. + * + * @param name + * The name of the project owner which is the username of the actual owners account. + * @return + * An option to successfully found project owner. + */ + def findProjectOwner(name: ProjectOwnerName): F[Option[ProjectOwner]] +} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Project.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Project.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Project.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Project.scala 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,314 @@ +/* + * 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.tickets + +import java.util.UUID + +import cats._ +import cats.data._ +import cats.syntax.all._ +import de.smederee.email.EmailAddress +import de.smederee.security.{ UserId, Username } + +import scala.util.matching.Regex + +opaque type ProjectDescription = String +object ProjectDescription { + val MaximumLength: Int = 8192 + + /** Create an instance of ProjectDescription from the given String type. + * + * @param source + * An instance of type String which will be returned as a ProjectDescription. + * @return + * The appropriate instance of ProjectDescription. + */ + def apply(source: String): ProjectDescription = source + + /** Try to create an instance of ProjectDescription from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a ProjectDescription. + * @return + * An option to the successfully converted ProjectDescription. + */ + def from(source: String): Option[ProjectDescription] = Option(source).map(_.take(MaximumLength)) + +} + +opaque type ProjectName = String +object ProjectName { + + given Eq[ProjectName] = Eq.fromUniversalEquals + + given Order[ProjectName] = Order.from((a, b) => a.toString.compareTo(b.toString)) + + // TODO Can we rewrite this in a Scala-3 way (i.e. without implicitly)? + given Ordering[ProjectName] = implicitly[Order[ProjectName]].toOrdering + + val Format: Regex = "^[a-zA-Z0-9][a-zA-Z0-9\\-_]{1,63}$".r + + /** Create an instance of ProjectName from the given String type. + * + * @param source + * An instance of type String which will be returned as a ProjectName. + * @return + * The appropriate instance of ProjectName. + */ + def apply(source: String): ProjectName = source + + /** Try to create an instance of ProjectName from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a ProjectName. + * @return + * An option to the successfully converted ProjectName. + */ + def from(source: String): Option[ProjectName] = validate(source).toOption + + /** Validate the given string and return either the validated repository name or a list of errors. + * + * @param s + * An arbitrary string which should be a repository name. + * @return + * Either a list of errors or the validated repository name. + */ + def validate(s: String): ValidatedNec[String, ProjectName] = + Option(s).map(_.trim.nonEmpty) match { + case Some(true) => + val input = s.trim + val miniumLength = + if (input.length > 1) + input.validNec + else + "Repository name too short (min. 2 characters)!".invalidNec + val maximumLength = + if (input.length < 65) + input.validNec + else + "Repository name too long (max. 64 characters)!".invalidNec + val validFormat = + if (Format.matches(input)) + input.validNec + else + "Repository name must start with a letter or number and is only allowed to contain alphanumeric characters plus .".invalidNec + (miniumLength, maximumLength, validFormat).mapN { case (_, _, name) => + name + } + case _ => "Repository name must not be empty!".invalidNec + } +} + +/** Extractor to retrieve a ProjectName from a path parameter. + */ +object ProjectNamePathParameter { + def unapply(str: String): Option[ProjectName] = Option(str).flatMap(ProjectName.from) +} + +/** A project owner id is supposed to be globally unique and maps to the [[java.util.UUID]] type beneath. + */ +opaque type ProjectOwnerId = UUID +object ProjectOwnerId { + val Format = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$".r + + given Eq[ProjectOwnerId] = Eq.fromUniversalEquals + + given Conversion[UserId, ProjectOwnerId] = ProjectOwnerId.fromUserId + + /** Create an instance of ProjectOwnerId from the given UUID type. + * + * @param source + * An instance of type UUID which will be returned as a ProjectOwnerId. + * @return + * The appropriate instance of ProjectOwnerId. + */ + def apply(source: UUID): ProjectOwnerId = source + + /** Try to create an instance of ProjectOwnerId from the given UUID. + * + * @param source + * A UUID that should fulfil the requirements to be converted into a ProjectOwnerId. + * @return + * An option to the successfully converted ProjectOwnerId. + */ + def from(source: UUID): Option[ProjectOwnerId] = Option(source) + + /** Try to create an instance of ProjectOwnerId from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a ProjectOwnerId. + * @return + * An option to the successfully converted ProjectOwnerId. + */ + def fromString(source: String): Either[String, ProjectOwnerId] = + Option(source) + .filter(s => Format.matches(s)) + .flatMap { uuidString => + Either.catchNonFatal(UUID.fromString(uuidString)).toOption + } + .toRight("Illegal value for ProjectOwnerId!") + + /** Create an instance of ProjectOwnerId from the given UserId type. + * + * @param uid + * An instance of type UserId which will be returned as a ProjectOwnerId. + * @return + * The appropriate instance of ProjectOwnerId. + */ + def fromUserId(uid: UserId): ProjectOwnerId = uid.toUUID + + /** Generate a new random project owner id. + * + * @return + * A project owner id which is pseudo randomly generated. + */ + def randomProjectOwnerId: ProjectOwnerId = UUID.randomUUID + + extension (uid: ProjectOwnerId) { + def toUUID: UUID = uid + } +} + +/** A project owner name for an account has to obey several restrictions which are similiar to the ones found for Unix + * usernames. It must start with a letter, contain only alphanumeric characters, be at least 2 and at most 31 + * characters long and be all lowercase. + */ +opaque type ProjectOwnerName = String +object ProjectOwnerName { + given Eq[ProjectOwnerName] = Eq.fromUniversalEquals + + given Conversion[Username, ProjectOwnerName] = ProjectOwnerName.fromUsername + + val isAlphanumeric = "^[a-z][a-z0-9]+$".r + + /** Create an instance of ProjectOwnerName from the given String type. + * + * @param source + * An instance of type String which will be returned as a ProjectOwnerName. + * @return + * The appropriate instance of ProjectOwnerName. + */ + def apply(source: String): ProjectOwnerName = source + + /** Try to create an instance of ProjectOwnerName from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a ProjectOwnerName. + * @return + * An option to the successfully converted ProjectOwnerName. + */ + def from(s: String): Option[ProjectOwnerName] = validate(s).toOption + + /** Create an instance of ProjectOwnerName from the given Username type. + * + * @param username + * An instance of the type Username which will be returned as a ProjectOwnerName. + * @return + * The appropriate instance of ProjectOwnerName. + */ + def fromUsername(username: Username): ProjectOwnerName = username.toString + + /** Validate the given string and return either the validated project owner name or a list of errors. + * + * @param s + * An arbitrary string which should be a project owner name. + * @return + * Either a list of errors or the validated project owner name. + */ + def validate(s: String): ValidatedNec[String, ProjectOwnerName] = + Option(s).map(_.trim.nonEmpty) match { + case Some(true) => + val input = s.trim + val miniumLength = + if (input.length >= 2) + input.validNec + else + "ProjectOwnerName too short (min. 2 characters)!".invalidNec + val maximumLength = + if (input.length < 32) + input.validNec + else + "ProjectOwnerName too long (max. 31 characters)!".invalidNec + val alphanumeric = + if (isAlphanumeric.matches(input)) + input.validNec + else + "ProjectOwnerName must be all lowercase alphanumeric characters and start with a character.".invalidNec + (miniumLength, maximumLength, alphanumeric).mapN { case (_, _, name) => + name + } + case _ => "ProjectOwnerName must not be empty!".invalidNec + } + + extension (ownername: ProjectOwnerName) { + + /** Convert this project owner name into a username. + * + * @return + * A syntactically valid username. + */ + def toUsername: Username = Username(ownername.toString) + } +} + +/** Extractor to retrieve an ProjectOwnerName from a path parameter. + */ +object ProjectOwnerNamePathParameter { + def unapply(str: String): Option[ProjectOwnerName] = + Option(str).flatMap { string => + if (string.startsWith("~")) + ProjectOwnerName.from(string.drop(1)) + else + None + } +} + +/** Descriptive information about the owner of a project. + * + * @param owner + * The unique ID of the project owner. + * @param name + * The name of the project owner which is supposed to be unique. + * @param email + * The email address of the project owner. + */ +final case class ProjectOwner(uid: ProjectOwnerId, name: ProjectOwnerName, email: EmailAddress) + +object ProjectOwner { + given Eq[ProjectOwner] = Eq.fromUniversalEquals +} + +/** A project is the base entity for tracking tickets. + * + * @param owner + * The owner of the project. + * @param name + * The name of the project. A project name must start with a letter or number and must contain only alphanumeric + * ASCII characters as well as minus or underscore signs. It must be between 2 and 64 characters long. + * @param description + * An optional short text description of the project. + * @param isPrivate + * A flag indicating if this project is private i.e. only visible / accessible for accounts with appropriate + * permissions. + */ +final case class Project( + owner: ProjectOwner, + name: ProjectName, + description: Option[ProjectDescription], + isPrivate: Boolean +) diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Submitter.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Submitter.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Submitter.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Submitter.scala 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,163 @@ +/* + * 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.tickets + +import java.util.UUID + +import cats._ +import cats.data._ +import cats.syntax.all._ + +/** A submitter id is supposed to be globally unique and maps to the [[java.util.UUID]] type beneath. + */ +opaque type SubmitterId = UUID +object SubmitterId { + val Format = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$".r + + given Eq[SubmitterId] = Eq.fromUniversalEquals + + /** Create an instance of SubmitterId from the given UUID type. + * + * @param source + * An instance of type UUID which will be returned as a SubmitterId. + * @return + * The appropriate instance of SubmitterId. + */ + def apply(source: UUID): SubmitterId = source + + /** Try to create an instance of SubmitterId from the given UUID. + * + * @param source + * A UUID that should fulfil the requirements to be converted into a SubmitterId. + * @return + * An option to the successfully converted SubmitterId. + */ + def from(source: UUID): Option[SubmitterId] = Option(source) + + /** Try to create an instance of SubmitterId from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a SubmitterId. + * @return + * An option to the successfully converted SubmitterId. + */ + def fromString(source: String): Either[String, SubmitterId] = + Option(source) + .filter(s => Format.matches(s)) + .flatMap { uuidString => + Either.catchNonFatal(UUID.fromString(uuidString)).toOption + } + .toRight("Illegal value for SubmitterId!") + + /** Generate a new random user id. + * + * @return + * A user id which is pseudo randomly generated. + */ + def randomSubmitterId: SubmitterId = UUID.randomUUID + + extension (uid: SubmitterId) { + def toUUID: UUID = uid + } +} + +/** A submitter name has to obey several restrictions which are similiar to the ones found for Unix usernames. It must + * start with a letter, contain only alphanumeric characters, be at least 2 and at most 31 characters long and be all + * lowercase. + */ +opaque type SubmitterName = String +object SubmitterName { + given Eq[SubmitterName] = Eq.fromUniversalEquals + + val isAlphanumeric = "^[a-z][a-z0-9]+$".r + + /** Create an instance of SubmitterName from the given String type. + * + * @param source + * An instance of type String which will be returned as a SubmitterName. + * @return + * The appropriate instance of SubmitterName. + */ + def apply(source: String): SubmitterName = source + + /** Try to create an instance of SubmitterName from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a SubmitterName. + * @return + * An option to the successfully converted SubmitterName. + */ + def from(s: String): Option[SubmitterName] = validate(s).toOption + + /** Validate the given string and return either the validated username or a list of errors. + * + * @param s + * An arbitrary string which should be a username. + * @return + * Either a list of errors or the validated username. + */ + def validate(s: String): ValidatedNec[String, SubmitterName] = + Option(s).map(_.trim.nonEmpty) match { + case Some(true) => + val input = s.trim + val miniumLength = + if (input.length >= 2) + input.validNec + else + "SubmitterName too short (min. 2 characters)!".invalidNec + val maximumLength = + if (input.length < 32) + input.validNec + else + "SubmitterName too long (max. 31 characters)!".invalidNec + val alphanumeric = + if (isAlphanumeric.matches(input)) + input.validNec + else + "SubmitterName must be all lowercase alphanumeric characters and start with a character.".invalidNec + (miniumLength, maximumLength, alphanumeric).mapN { case (_, _, name) => + name + } + case _ => "SubmitterName must not be empty!".invalidNec + } +} + +/** Extractor to retrieve an SubmitterName from a path parameter. + */ +object SubmitterNamePathParameter { + def unapply(str: String): Option[SubmitterName] = + Option(str).flatMap { string => + if (string.startsWith("~")) + SubmitterName.from(string.drop(1)) + else + None + } +} + +/** The submitter for a ticket i.e. the person supposed to be working on it. + * + * @param id + * A globally unique ID identifying the submitter. + * @param name + * The name associated with the submitter which is supposed to be unique. + */ +final case class Submitter(id: SubmitterId, name: SubmitterName) + +object Submitter { + given Eq[Submitter] = Eq.fromUniversalEquals +} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketRepository.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketRepository.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketRepository.scala 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,193 @@ +/* + * 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.tickets + +import fs2.Stream + +/** The base class that defines the needed functionality to handle tickets and related data types within a database. + * + * @tparam F + * A higher kinded type which wraps the actual return values. + */ +abstract class TicketRepository[F[_]] { + + /** Add the given assignee to the ticket of the given repository id. + * + * @param vcsRepositoryId + * The unique internal ID of a vcs repository metadata entry. + * @param ticketNumber + * The unique identifier of a ticket within the project scope is its number. + * @param assignee + * The assignee to be added to the ticket. + * @return + * The number of affected database rows. + */ + def addAssignee(vcsRepositoryId: Long)(ticketNumber: TicketNumber)(assignee: Assignee): F[Int] + + /** Add the given label to the ticket of the given repository id. + * + * @param vcsRepositoryId + * The unique internal ID of a vcs repository metadata entry. + * @param ticketNumber + * The unique identifier of a ticket within the project scope is its number. + * @param label + * The label to be added to the ticket. + * @return + * The number of affected database rows. + */ + def addLabel(vcsRepositoryId: Long)(ticketNumber: TicketNumber)(label: Label): F[Int] + + /** Add the given milestone to the ticket of the given repository id. + * + * @param vcsRepositoryId + * The unique internal ID of a vcs repository metadata entry. + * @param ticketNumber + * The unique identifier of a ticket within the project scope is its number. + * @param milestone + * The milestone to be added to the ticket. + * @return + * The number of affected database rows. + */ + def addMilestone(vcsRepositoryId: Long)(ticketNumber: TicketNumber)(milestone: Milestone): F[Int] + + /** Return all tickets associated with the given repository. + * + * @param vcsRepositoryId + * The unique internal ID of a vcs repository metadata entry for which all tickets shall be returned. + * @return + * A stream of tickets associated with the vcs repository which may be empty. + */ + def allTickets(vcsRepositoryId: Long): Stream[F, Ticket] + + /** Create a database entry for the given ticket definition within the scope of the repository with the given id. + * + * @param vcsRepositoryId + * The unique internal ID of a vcs repository metadata entry. + * @param ticket + * The ticket definition that shall be written to the database. + * @return + * The number of affected database rows. + */ + def createTicket(vcsRepositoryId: Long)(ticket: Ticket): F[Int] + + /** Delete the ticket of the repository with the given id from the database. + * + * @param vcsRepositoryId + * The unique internal ID of a vcs repository metadata entry. + * @param ticket + * The ticket definition that shall be deleted from the database. + * @return + * The number of affected database rows. + */ + def deleteTicket(vcsRepositoryId: Long)(ticket: Ticket): F[Int] + + /** Find the ticket with the given number of the repository with the given id. + * + * @param vcsRepositoryId + * The unique internal ID of a vcs repository metadata entry. + * @param ticketNumber + * The unique identifier of a ticket within the project scope is its number. + * @return + * An option to the found ticket. + */ + def findTicket(vcsRepositoryId: Long)(ticketNumber: TicketNumber): F[Option[Ticket]] + + /** Load all assignees that are assigned to the ticket with the given number and repository id. + * + * @param vcsRepositoryId + * The unique internal ID of a vcs repository metadata entry. + * @param ticketNumber + * The unique identifier of a ticket within the project scope is its number. + * @return + * A stream of assigness that may be empty. + */ + def loadAssignees(vcsRepositoryId: Long)(ticketNumber: TicketNumber): Stream[F, Assignee] + + /** Load all labels that are attached to the ticket with the given number and repository id. + * + * @param vcsRepositoryId + * The unique internal ID of a vcs repository metadata entry. + * @param ticketNumber + * The unique identifier of a ticket within the project scope is its number. + * @return + * A stream of labels that may be empty. + */ + def loadLabels(vcsRepositoryId: Long)(ticketNumber: TicketNumber): Stream[F, Label] + + /** Load all milestones that are attached to the ticket with the given number and repository id. + * + * @param vcsRepositoryId + * The unique internal ID of a vcs repository metadata entry. + * @param ticketNumber + * The unique identifier of a ticket within the project scope is its number. + * @return + * A stream of milestones that may be empty. + */ + def loadMilestones(vcsRepositoryId: Long)(ticketNumber: TicketNumber): Stream[F, Milestone] + + /** Remove the given assignee from the ticket of the given repository id. + * + * @param vcsRepositoryId + * The unique internal ID of a vcs repository metadata entry. + * @param ticketNumber + * The unique identifier of a ticket within the project scope is its number. + * @param assignee + * The assignee to be removed from the ticket. + * @return + * The number of affected database rows. + */ + def removeAssignee(vcsRepositoryId: Long)(ticket: Ticket)(assignee: Assignee): F[Int] + + /** Remove the given label from the ticket of the given repository id. + * + * @param vcsRepositoryId + * The unique internal ID of a vcs repository metadata entry. + * @param ticketNumber + * The unique identifier of a ticket within the project scope is its number. + * @param label + * The label to be removed from the ticket. + * @return + * The number of affected database rows. + */ + def removeLabel(vcsRepositoryId: Long)(ticket: Ticket)(label: Label): F[Int] + + /** Remove the given milestone from the ticket of the given repository id. + * + * @param vcsRepositoryId + * The unique internal ID of a vcs repository metadata entry. + * @param ticketNumber + * The unique identifier of a ticket within the project scope is its number. + * @param milestone + * The milestone to be removed from the ticket. + * @return + * The number of affected database rows. + */ + def removeMilestone(vcsRepositoryId: Long)(ticket: Ticket)(milestone: Milestone): F[Int] + + /** Update the database entry for the given ticket within the scope of the repository with the given id. + * + * @param vcsRepositoryId + * The unique internal ID of a vcs repository metadata entry. + * @param ticket + * The ticket definition that shall be updated within the database. + * @return + * The number of affected database rows. + */ + def updateTicket(vcsRepositoryId: Long)(ticket: Ticket): F[Int] + +} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,202 @@ +/* + * 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.tickets + +import java.time.OffsetDateTime + +import cats._ + +/** An unlimited text field which must be not empty to describe the ticket in great detail if needed. + */ +opaque type TicketContent = String +object TicketContent { + + /** Create an instance of TicketContent from the given String type. + * + * @param source + * An instance of type String which will be returned as a TicketContent. + * @return + * The appropriate instance of TicketContent. + */ + def apply(source: String): TicketContent = source + + /** Try to create an instance of TicketContent from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a TicketContent. + * @return + * An option to the successfully converted TicketContent. + */ + def from(source: String): Option[TicketContent] = Option(source).filter(_.nonEmpty) + +} + +/** A ticket number maps to an integer beneath and has the requirement to be greater than zero. + */ +opaque type TicketNumber = Int +object TicketNumber { + + /** Create an instance of TicketNumber from the given Int type. + * + * @param source + * An instance of type Int which will be returned as a TicketNumber. + * @return + * The appropriate instance of TicketNumber. + */ + def apply(source: Int): TicketNumber = source + + /** Try to create an instance of TicketNumber from the given Int. + * + * @param source + * A Int that should fulfil the requirements to be converted into a TicketNumber. + * @return + * An option to the successfully converted TicketNumber. + */ + def from(source: Int): Option[TicketNumber] = Option(source).filter(_ > 0) +} + +/** Possible states of a ticket which shall model the life cycle from ticket creation until it is closed. To keep things + * simple we do not model the kind of a resolution (e.g. fixed, won't fix or so) for a ticket. + */ +enum TicketStatus { + + /** The ticket is considered to be a valid ticket / ready to be worked on e.g. a bug has been confirmed to be present. + */ + case Confirmed + + /** The ticket is being worked on i.e. it is in progress. + */ + case InProgress + + /** The ticket is pending and cannot be processed right now. It may be moved to another state or closed depending on + * the circumstances. This could be used to model the "blocked" state of Kanban. + */ + case Pending + + /** The ticket is resolved (i.e. closed) and considered done. + */ + case Resolved + + /** The ticket was reported and is open for further investigation. This might map to what is often called "Backlog" + * nowadays. + */ + case Submitted +} + +object TicketStatus { + given Eq[TicketStatus] = Eq.fromUniversalEquals +} + +/** Possible types of "resolved states" of a ticket. + */ +enum TicketResolution { + + /** The behaviour / scenario described in the ticket is caused by the design of the application and not considered to + * be a bug. + */ + case ByDesign + + /** The ticket is finally closed and considered done. + * + * This state can be used to model a review process e.g. a developer can move a ticket to `Fixed` and reviewer and + * tester can later move the ticket to `Closed`. + */ + case Closed + + /** The ticket is a duplicate of an already existing one. + */ + case Duplicate + + /** The bug described in the ticket was fixed. + */ + case Fixed + + /** The feature described in the ticket was implemented. + */ + case Implemented + + /** The ticket is considered to be invalid. + */ + case Invalid + + /** The issue described in the ticket will not be fixed. + */ + case WontFix +} + +object TicketResolution { + given Eq[TicketResolution] = Eq.fromUniversalEquals +} + +/** A concise and short description of the ticket which should not exceed 80 characters. + */ +opaque type TicketTitle = String +object TicketTitle { + + val MaxLength: Int = 72 + + /** Create an instance of TicketTitle from the given String type. + * + * @param source + * An instance of type String which will be returned as a TicketTitle. + * @return + * The appropriate instance of TicketTitle. + */ + def apply(source: String): TicketTitle = source + + /** Try to create an instance of TicketTitle from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a TicketTitle. + * @return + * An option to the successfully converted TicketTitle. + */ + def from(source: String): Option[TicketTitle] = + Option(source).filter(string => string.nonEmpty && string.length <= MaxLength) +} + +/** An ticket used to describe a problem or a task (e.g. implement a concrete feature) within the scope of a project. + * + * @param number + * The unique identifier of a ticket within the project scope is its number. + * @param title + * A concise and short description of the ticket which should not exceed 72 characters. + * @param content + * An optional field to describe the ticket in great detail if needed. + * @param status + * The current status of the ticket describing its life cycle. + * @param resolution + * An optional resolution state of the ticket that should be set if it is closed. + * @param submitter + * The person who submitted (created) this ticket which is optional because of possible account deletion or other + * reasons. + * @param createdAt + * The timestamp when the ticket was created / submitted. + * @param updatedAt + * The timestamp when the ticket was last updated. Upon creation the update time equals the creation time. + */ +final case class Ticket( + number: TicketNumber, + title: TicketTitle, + content: Option[TicketContent], + status: TicketStatus, + resolution: Option[TicketResolution], + submitter: Option[Submitter], + createdAt: OffsetDateTime, + updatedAt: OffsetDateTime +) diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketsUser.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketsUser.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketsUser.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketsUser.scala 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,37 @@ +/* + * 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.semderee.tickets + +import cats._ +import de.smederee.email.EmailAddress +import de.smederee.security.{ UserId, Username } + +/** A user of the tickets service. + * + * @param uid + * The unique ID of the user. + * @param name + * A unique name which can be used for login and to identify the user. + * @param email + * The email address of the user which must also be unique. + */ +final case class TicketsUser(uid: UserId, name: Username, email: EmailAddress) + +object TicketsUser { + given Eq[TicketsUser] = Eq.fromUniversalEquals +} diff -rN -u old-smederee/modules/tickets/src/test/resources/logback-test.xml new-smederee/modules/tickets/src/test/resources/logback-test.xml --- old-smederee/modules/tickets/src/test/resources/logback-test.xml 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/test/resources/logback-test.xml 2025-01-31 10:46:28.186967333 +0000 @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="UTF-8"?> +<configuration debug="false"> + <appender name="console" class="ch.qos.logback.core.ConsoleAppender"> + <filter class="ch.qos.logback.classic.filter.ThresholdFilter"> + <level>WARN</level> + </filter> + <encoder> + <pattern>%date %highlight(%-5level) %cyan(%logger{0}) - %msg%n</pattern> + </encoder> + </appender> + + <appender name="async-console" class="ch.qos.logback.classic.AsyncAppender"> + <appender-ref ref="console"/> + <queueSize>5000</queueSize> + <discardingThreshold>0</discardingThreshold> + </appender> + + <logger name="de.smederee.tickets" level="DEBUG" additivity="false"> + <appender-ref ref="async-console"/> + </logger> + + <root> + <appender-ref ref="async-console"/> + </root> +</configuration> diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/ColourCodeTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/ColourCodeTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/ColourCodeTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/ColourCodeTest.scala 2025-01-31 10:46:28.190967340 +0000 @@ -0,0 +1,41 @@ +/* + * 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.tickets + +import de.smederee.tickets.Generators._ + +import munit._ +import org.scalacheck._ +import org.scalacheck.Prop._ + +final class ColourCodeTest extends ScalaCheckSuite { + given Arbitrary[ColourCode] = Arbitrary(genColourCode) + + property("ColourCode.from must fail on invalid input") { + forAll { (input: String) => + assertEquals(ColourCode.from(input), None) + } + } + + property("ColourCode.from must succeed on valid input") { + forAll { (colourCode: ColourCode) => + val input = colourCode.toString + assertEquals(ColourCode.from(input), Option(colourCode)) + } + } +} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala 2025-01-31 10:46:28.190967340 +0000 @@ -0,0 +1,146 @@ +/* + * 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.tickets + +import java.time._ +import java.util.Locale + +import cats.syntax.all._ + +import org.scalacheck.{ Arbitrary, Gen } + +import scala.jdk.CollectionConverters._ + +object Generators { + val MinimumYear: Int = -4713 // Lowest year supported by PostgreSQL + val MaximumYear: Int = 294276 // Highest year supported by PostgreSQL + + /** Prepend a zero to a single character hexadecimal code. + * + * @param hexCode + * A string supposed to contain a hexadecimal code between 0 and ff. + * @return + * Either the given code prepended with a leading zero if it had only a single character or the originally given + * code otherwise. + */ + private def hexPadding(hexCode: String): String = + if (hexCode.length < 2) + "0" + hexCode + else + hexCode + + val genLocalDate: Gen[LocalDate] = + for { + year <- Gen.choose(MinimumYear, MaximumYear) + month <- Gen.choose(1, 12) + day <- Gen.choose(1, Month.of(month).length(Year.of(year).isLeap)) + } yield LocalDate.of(year, month, day) + + given Arbitrary[LocalDate] = Arbitrary(genLocalDate) + + val genOffsetDateTime: Gen[OffsetDateTime] = + for { + year <- Gen.choose(MinimumYear, MaximumYear) + month <- Gen.choose(1, 12) + day <- Gen.choose(1, Month.of(month).length(Year.of(year).isLeap)) + hour <- Gen.choose(0, 23) + minute <- Gen.choose(0, 59) + second <- Gen.choose(0, 59) + nanosecond <- Gen.const(0) // Avoid issues with loosing information during saving and loading. + offset <- Gen.oneOf( + ZoneId.getAvailableZoneIds.asScala.toList.map(ZoneId.of).map(ZonedDateTime.now).map(_.getOffset) + ) + } yield OffsetDateTime.of(year, month, day, hour, minute, second, nanosecond, offset) + + val genSubmitterId: Gen[SubmitterId] = Gen.delay(SubmitterId.randomSubmitterId) + + val genValidSubmitterName: Gen[SubmitterName] = for { + length <- Gen.choose(2, 30) + prefix <- Gen.alphaChar + chars <- Gen + .nonEmptyListOf(Gen.alphaNumChar) + .map(_.take(length).mkString.toLowerCase(Locale.ROOT)) + } yield SubmitterName(prefix.toString.toLowerCase(Locale.ROOT) + chars) + + val genLabelName: Gen[LabelName] = + Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(LabelName.MaxLength).mkString).map(LabelName.apply) + + val genLabelDescription: Gen[LabelDescription] = + Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(LabelDescription.MaxLength).mkString).map(LabelDescription.apply) + + val genColourCode: Gen[ColourCode] = for { + red <- Gen.choose(0, 255).map(_.toHexString).map(hexPadding) + green <- Gen.choose(0, 255).map(_.toHexString).map(hexPadding) + blue <- Gen.choose(0, 255).map(_.toHexString).map(hexPadding) + hexString = s"#$red$green$blue" + } yield ColourCode(hexString) + + val genLabel: Gen[Label] = for { + id <- Gen.option(Gen.choose(0L, Long.MaxValue).map(LabelId.apply)) + name <- genLabelName + description <- Gen.option(genLabelDescription) + colour <- genColourCode + } yield Label(id, name, description, colour) + + val genLabels: Gen[List[Label]] = Gen.nonEmptyListOf(genLabel).map(_.distinct) + + val genMilestoneTitle: Gen[MilestoneTitle] = + Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(MilestoneTitle.MaxLength).mkString).map(MilestoneTitle.apply) + + val genMilestoneDescription: Gen[MilestoneDescription] = + Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.mkString).map(MilestoneDescription.apply) + + val genMilestone: Gen[Milestone] = + for { + id <- Gen.option(Gen.choose(0L, Long.MaxValue).map(MilestoneId.apply)) + title <- genMilestoneTitle + due <- Gen.option(genLocalDate) + descr <- Gen.option(genMilestoneDescription) + } yield Milestone(id, title, descr, due) + + val genMilestones: Gen[List[Milestone]] = Gen.nonEmptyListOf(genMilestone).map(_.distinct) + + val genSubmitter: Gen[Submitter] = + for { + uid <- genSubmitterId + name <- genValidSubmitterName + } yield Submitter(uid, name) + + val genTicket: Gen[Ticket] = + for { + ticketNumber <- Gen.choose(0, Int.MaxValue).map(TicketNumber.apply) + ticketTitle <- Gen + .nonEmptyListOf(Gen.alphaNumChar) + .map(chars => TicketTitle(chars.take(TicketTitle.MaxLength).mkString)) + ticketContent <- Gen.alphaNumStr.map(TicketContent.from) + ticketStatus <- Gen.oneOf(TicketStatus.values.toList) + ticketResolution <- Gen.option(Gen.oneOf(TicketResolution.values.toList)) + submitter <- Gen.option(genSubmitter) + createdAt <- genOffsetDateTime + updatedAt <- genOffsetDateTime + } yield Ticket( + number = ticketNumber, + title = ticketTitle, + content = ticketContent, + status = ticketStatus, + resolution = ticketResolution.filter(_ => ticketStatus === TicketStatus.Resolved), + submitter = submitter, + createdAt = createdAt, + updatedAt = updatedAt + ) +} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala 2025-01-31 10:46:28.190967340 +0000 @@ -0,0 +1,46 @@ +/* + * 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.tickets + +import de.smederee.tickets.Generators._ + +import munit._ +import org.scalacheck._ +import org.scalacheck.Prop._ + +final class LabelDescriptionTest extends ScalaCheckSuite { + given Arbitrary[LabelDescription] = Arbitrary(genLabelDescription) + + test("LabelDescription.from must fail on empty input") { + assertEquals(LabelDescription.from(""), None) + } + + property("LabelDescription.from must fail on too long input") { + forAll { (input: String) => + if (input.length > LabelDescription.MaxLength) + assertEquals(LabelDescription.from(input), None) + } + } + + property("LabelDescription.from must succeed on valid input") { + forAll { (label: LabelDescription) => + val input = label.toString + assertEquals(LabelDescription.from(input), Option(label)) + } + } +} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelNameTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelNameTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelNameTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelNameTest.scala 2025-01-31 10:46:28.190967340 +0000 @@ -0,0 +1,46 @@ +/* + * 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.tickets + +import de.smederee.tickets.Generators._ + +import munit._ +import org.scalacheck._ +import org.scalacheck.Prop._ + +final class LabelNameTest extends ScalaCheckSuite { + given Arbitrary[LabelName] = Arbitrary(genLabelName) + + test("LabelName.from must fail on empty input") { + assertEquals(LabelName.from(""), None) + } + + property("LabelName.from must fail on too long input") { + forAll { (input: String) => + if (input.length > LabelName.MaxLength) + assertEquals(LabelName.from(input), None) + } + } + + property("LabelName.from must succeed on valid input") { + forAll { (label: LabelName) => + val input = label.toString + assertEquals(LabelName.from(input), Option(label)) + } + } +} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/LabelTest.scala 2025-01-31 10:46:28.190967340 +0000 @@ -0,0 +1,35 @@ +/* + * 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.tickets + +import cats.syntax.all._ +import de.smederee.tickets.Generators._ + +import munit._ +import org.scalacheck._ +import org.scalacheck.Prop._ + +final class LabelTest extends ScalaCheckSuite { + given Arbitrary[Label] = Arbitrary(genLabel) + + property("Eq must hold") { + forAll { (label: Label) => + assert(label === label, "Identical labels must be considered equal!") + } + } +} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala 2025-01-31 10:46:28.190967340 +0000 @@ -0,0 +1,39 @@ +/* + * 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.tickets + +import de.smederee.tickets.Generators._ + +import munit._ +import org.scalacheck._ +import org.scalacheck.Prop._ + +final class MilestoneDescriptionTest extends ScalaCheckSuite { + given Arbitrary[MilestoneDescription] = Arbitrary(genMilestoneDescription) + + test("MilestoneDescription.from must fail on empty input") { + assertEquals(MilestoneDescription.from(""), None) + } + + property("MilestoneDescription.from must succeed on valid input") { + forAll { (label: MilestoneDescription) => + val input = label.toString + assertEquals(MilestoneDescription.from(input), Option(label)) + } + } +} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTest.scala 2025-01-31 10:46:28.190967340 +0000 @@ -0,0 +1,35 @@ +/* + * 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.tickets + +import cats.syntax.all._ +import de.smederee.tickets.Generators._ + +import munit._ +import org.scalacheck._ +import org.scalacheck.Prop._ + +final class MilestoneTest extends ScalaCheckSuite { + given Arbitrary[Milestone] = Arbitrary(genMilestone) + + property("Eq must hold") { + forAll { (label: Milestone) => + assert(label === label, "Identical milestones must be considered equal!") + } + } +} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala 2025-01-31 10:46:28.190967340 +0000 @@ -0,0 +1,46 @@ +/* + * 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.tickets + +import de.smederee.tickets.Generators._ + +import munit._ +import org.scalacheck._ +import org.scalacheck.Prop._ + +final class MilestoneTitleTest extends ScalaCheckSuite { + given Arbitrary[MilestoneTitle] = Arbitrary(genMilestoneTitle) + + test("MilestoneTitle.from must fail on empty input") { + assertEquals(MilestoneTitle.from(""), None) + } + + property("MilestoneTitle.from must fail on too long input") { + forAll { (input: String) => + if (input.length > MilestoneTitle.MaxLength) + assertEquals(MilestoneTitle.from(input), None) + } + } + + property("MilestoneTitle.from must succeed on valid input") { + forAll { (label: MilestoneTitle) => + val input = label.toString + assertEquals(MilestoneTitle.from(input), Option(label)) + } + } +} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketContentTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketContentTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketContentTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketContentTest.scala 2025-01-31 10:46:28.190967340 +0000 @@ -0,0 +1,35 @@ +/* + * 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.tickets + +import munit._ +import org.scalacheck._ +import org.scalacheck.Prop._ + +final class TicketContentTest extends ScalaCheckSuite { + + property("TicketContent.from must only accept valid input") { + forAll { (input: String) => + if (input.nonEmpty) + assertEquals(TicketContent.from(input), Some(TicketContent(input))) + else + assertEquals(TicketContent.from(input), None) + } + } + +} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketNumberTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketNumberTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketNumberTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketNumberTest.scala 2025-01-31 10:46:28.190967340 +0000 @@ -0,0 +1,35 @@ +/* + * 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.tickets + +import munit._ +import org.scalacheck._ +import org.scalacheck.Prop._ + +final class TicketNumberTest extends ScalaCheckSuite { + + property("TicketNumber.from must only accept valid input") { + forAll { (integer: Int) => + if (integer > 0) + assertEquals(TicketNumber.from(integer), Some(TicketNumber(integer))) + else + assertEquals(TicketNumber.from(integer), None) + } + } + +} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketTitleTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketTitleTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketTitleTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketTitleTest.scala 2025-01-31 10:46:28.190967340 +0000 @@ -0,0 +1,35 @@ +/* + * 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.tickets + +import munit._ +import org.scalacheck._ +import org.scalacheck.Prop._ + +final class TicketTitleTest extends ScalaCheckSuite { + + property("TicketTitle.from must only accept valid input") { + forAll { (input: String) => + if (input.nonEmpty && input.length <= TicketTitle.MaxLength) + assertEquals(TicketTitle.from(input), Some(TicketTitle(input))) + else + assertEquals(TicketTitle.from(input), None) + } + } + +} diff -rN -u old-smederee/project/plugins.sbt new-smederee/project/plugins.sbt --- old-smederee/project/plugins.sbt 2025-01-31 10:46:28.166967299 +0000 +++ new-smederee/project/plugins.sbt 2025-01-31 10:46:28.190967340 +0000 @@ -4,6 +4,6 @@ addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.10.4") addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.0") addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.6") -addSbtPlugin("com.typesafe.play" % "sbt-twirl" % "1.6.0-RC1") +addSbtPlugin("com.typesafe.play" % "sbt-twirl" % "1.6.0-RC2") // Needed to build debian packages via java (for sbt-native-packager). libraryDependencies += "org.vafer" % "jdeb" % "1.10" artifacts (Artifact("jdeb", "jar", "jar")) diff -rN -u old-smederee/twirl/src/main/scala/org/http4s/twirl/TwirlInstances.scala new-smederee/twirl/src/main/scala/org/http4s/twirl/TwirlInstances.scala --- old-smederee/twirl/src/main/scala/org/http4s/twirl/TwirlInstances.scala 2025-01-31 10:46:28.166967299 +0000 +++ new-smederee/twirl/src/main/scala/org/http4s/twirl/TwirlInstances.scala 2025-01-31 10:46:28.190967340 +0000 @@ -20,7 +20,6 @@ import _root_.play.twirl.api._ import org.http4s.Charset.`UTF-8` -import org.http4s.MediaType import org.http4s.headers.`Content-Type` @SuppressWarnings(Array("scalafix:DisableSyntax.defaultArgs"))