~jan0sch/smederee
Showing details for patch 7a198c7f10e5c6dc3a980897adec7b0ffbf6403e.
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 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala 2025-02-02 16:56:14.782979214 +0000 @@ -0,0 +1,390 @@ +/* + * Copyright (c) 2022 Wegtam GmbH + * + * Business Source License 1.1 - See file LICENSE for details! + * + * Change Date: 2025-06-21 + * Change License: GNU AFFERO GENERAL PUBLIC LICENSE Version 3 + */ + +package de.smederee.hub + +import cats.effect._ +import cats.syntax.all._ +import de.smederee.hub.Generators._ +import de.smederee.hub.VcsMetadataRepositoriesOrdering._ +import de.smederee.hub.config.SmedereeHubConfig +import doobie._ +import org.flywaydb.core.Flyway + +import munit._ + +final class DoobieVcsMetadataRepositoryTest extends BaseSpec { + + /** 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 findVcsRepositoryId(owner: UserId, name: VcsRepositoryName): IO[Option[Long]] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay( + con.prepareStatement( + """SELECT id FROM "repositories" 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 + } + + override def beforeEach(context: BeforeEach): Unit = { + val dbConfig = configuration.database + val flyway: Flyway = Flyway.configure().dataSource(dbConfig.url, dbConfig.user, dbConfig.pass).load() + val _ = flyway.migrate() + val _ = flyway.clean() + val _ = flyway.migrate() + } + + override def afterEach(context: AfterEach): Unit = { + val dbConfig = configuration.database + val flyway: Flyway = Flyway.configure().dataSource(dbConfig.url, dbConfig.user, dbConfig.pass).load() + val _ = flyway.migrate() + val _ = flyway.clean() + } + + test("createVcsRepository must create a repository entry") { + (genValidAccount.sample, genValidVcsRepository.sample) match { + case (Some(account), Some(repository)) => + val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieVcsMetadataRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + written <- repo.createVcsRepository(vcsRepository) + } yield written + test.map { written => + assert(written === 1, "Creating a vcs repository must modify one database row!") + } + case _ => fail("Could not generate data samples!") + } + } + + test("createVcsRepository must fail if the user does not exist") { + genValidVcsRepository.sample match { + case Some(repository) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieVcsMetadataRepository[IO](tx) + val test = for { + written <- repo.createVcsRepository(repository) + } yield written + test.attempt.map(result => assert(result.isLeft)) + case _ => fail("Could not generate data samples!") + } + } + + test("createVcsRepository must fail if a repository with the same name exists") { + (genValidAccount.sample, genValidVcsRepository.sample) match { + case (Some(account), Some(repository)) => + val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieVcsMetadataRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + _ <- repo.createVcsRepository(vcsRepository) + written <- repo.createVcsRepository(vcsRepository) + } yield written + test.attempt.map(result => assert(result.isLeft)) + case _ => fail("Could not generate data samples!") + } + } + + test("findVcsRepository must return an existing repository") { + (genValidAccount.sample, genValidVcsRepository.sample) match { + case (Some(account), Some(repository)) => + val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieVcsMetadataRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + written <- repo.createVcsRepository(vcsRepository) + foundRepo <- repo.findVcsRepository(vcsRepository.owner, vcsRepository.name) + } yield (written, foundRepo) + test.map { result => + val (written, foundRepo) = result + assert(written === 1, "Test repository data was not written to database!") + foundRepo match { + case None => fail("Repository was not found!") + case Some(repo) => assert(repo === vcsRepository) + } + } + case _ => fail("Could not generate data samples!") + } + } + + test("findVcsRepositoryId must return the id of an existing repository") { + (genValidAccount.sample, genValidVcsRepository.sample) match { + case (Some(account), Some(repository)) => + val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieVcsMetadataRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + writtenRows <- repo.createVcsRepository(vcsRepository) + writtenId <- findVcsRepositoryId(vcsRepository.owner.uid, vcsRepository.name) + foundId <- repo.findVcsRepositoryId(vcsRepository.owner, vcsRepository.name) + } yield (writtenRows, writtenId, foundId) + test.map { result => + val (written, writtenId, foundId) = result + assert(written === 1, "Test repository data was not written to database!") + assert(writtenId.nonEmpty) + assert(foundId.nonEmpty) + assert(writtenId === foundId) + } + case _ => fail("Could not generate data samples!") + } + } + + test("findVcsRepositoryOwner must return the correct account") { + genValidAccounts.sample match { + case Some(accounts) => + val expectedOwner = accounts(scala.util.Random.nextInt(accounts.size)).toVcsRepositoryOwner + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieVcsMetadataRepository[IO](tx) + val test = for { + written <- accounts.traverse(account => + createAccount(account, PasswordHash("I am not a password hash!"), None, None) + ) + foundOwner <- repo.findVcsRepositoryOwner(expectedOwner.name) + } yield (written, foundOwner) + test.map { result => + val (written, foundOwner) = result + assert( + written.filter(_ === 1).size === written.size, + "Not all test repository data was not written to database!" + ) + foundOwner match { + case None => fail("Vcs repository owner not found!") + case Some(owner) => assertEquals(owner, expectedOwner) + } + } + case _ => fail("Could not generate data samples!") + } + } + + test("listAllRepositories must return only public repositories for guest users") { + (genValidAccount.sample, genValidVcsRepositories.sample) match { + case (Some(account), Some(repositories)) => + val vcsRepositories = repositories + val accounts = vcsRepositories.map(repo => + Account( + repo.owner.uid, + repo.owner.name, + Email(s"${repo.owner.name}@example.com"), + verifiedEmail = true + ) + ) + val expectedRepoList = vcsRepositories.filter(_.isPrivate === false) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieVcsMetadataRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + _ <- accounts.traverse(account => + createAccount(account, PasswordHash("I am not a password hash!"), None, None) + ) + written <- vcsRepositories.traverse(vcsRepository => repo.createVcsRepository(vcsRepository)) + foundRepos <- repo.listAllRepositories(None)(NameAscending).compile.toList + } yield (written, foundRepos) + test.map { result => + val (written, foundRepos) = result + assert( + written.filter(_ === 1).size === written.size, + "Not all test repository data was not written to database!" + ) + assertEquals(foundRepos.size, expectedRepoList.size) + } + + case _ => fail("Could not generate data samples!") + } + } + + test("listAllRepositories must return only public repositories of others for any user") { + (genValidAccount.sample, genValidVcsRepositories.sample) match { + case (Some(account), Some(repositories)) => + val vcsRepositories = repositories + val accounts = vcsRepositories.map(repo => + Account( + repo.owner.uid, + repo.owner.name, + Email(s"${repo.owner.name}@example.com"), + verifiedEmail = true + ) + ) + val expectedRepoList = vcsRepositories.filter(_.isPrivate === false) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieVcsMetadataRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + _ <- accounts.traverse(account => + createAccount(account, PasswordHash("I am not a password hash!"), None, None) + ) + written <- vcsRepositories.traverse(vcsRepository => repo.createVcsRepository(vcsRepository)) + foundRepos <- repo.listAllRepositories(account.some)(NameDescending).compile.toList + } yield (written, foundRepos) + test.map { result => + val (written, foundRepos) = result + assert( + written.filter(_ === 1).size === written.size, + "Not all test repository data was not written to database!" + ) + assertEquals(foundRepos.size, expectedRepoList.size) + } + + case _ => fail("Could not generate data samples!") + } + } + + test("listAllRepositories must include all private repositories of the user") { + (genValidAccount.sample, genValidVcsRepositories.sample) match { + case (Some(account), Some(repositories)) => + val privateRepos = + repositories.filter(_.isPrivate === true).map(_.copy(owner = account.toVcsRepositoryOwner)) + val publicRepos = repositories.filter(_.isPrivate === false) + val vcsRepositories = privateRepos ::: publicRepos + val accounts = publicRepos.map(repo => + Account( + repo.owner.uid, + repo.owner.name, + Email(s"${repo.owner.name}@example.com"), + verifiedEmail = true + ) + ) + val expectedRepoList = vcsRepositories + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieVcsMetadataRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + _ <- accounts.traverse(account => + createAccount(account, PasswordHash("I am not a password hash!"), None, None) + ) + written <- vcsRepositories.traverse(vcsRepository => repo.createVcsRepository(vcsRepository)) + foundRepos <- repo.listAllRepositories(account.some)(NameDescending).compile.toList + } yield (written, foundRepos) + test.map { result => + val (written, foundRepos) = result + assert( + written.filter(_ === 1).size === written.size, + "Not all test repository data was not written to database!" + ) + assertEquals(foundRepos.size, expectedRepoList.size) + } + + case _ => fail("Could not generate data samples!") + } + } + + test("listRepositories must return only public repositories for guest users") { + (genValidAccount.sample, genValidVcsRepositories.sample) match { + case (Some(account), Some(repositories)) => + val vcsRepositories = repositories.map(_.copy(owner = account.toVcsRepositoryOwner)) + val expectedRepoList = vcsRepositories.filter(_.isPrivate === false).sortBy(_.name) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieVcsMetadataRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + written <- vcsRepositories.traverse(vcsRepository => repo.createVcsRepository(vcsRepository)) + foundRepos <- repo.listRepositories(None)(account.toVcsRepositoryOwner).compile.toList + } yield (written, foundRepos) + test.map { result => + val (written, foundRepos) = result + assert( + written.filter(_ === 1).size === written.size, + "Not all test repository data was not written to database!" + ) + assertEquals(foundRepos.size, expectedRepoList.size) + // We sort again because database sorting might differ slightly from code sorting. + assertEquals(foundRepos.sortBy(_.name), expectedRepoList) + } + case _ => fail("Could not generate data samples!") + } + } + + test("listRepositories must return all repositories for the owner") { + (genValidAccount.sample, genValidVcsRepositories.sample) match { + case (Some(account), Some(repositories)) => + val vcsRepositories = repositories.map(_.copy(owner = account.toVcsRepositoryOwner)) + val expectedRepoList = vcsRepositories.sortBy(_.name) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieVcsMetadataRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + written <- vcsRepositories.traverse(vcsRepository => repo.createVcsRepository(vcsRepository)) + foundRepos <- repo.listRepositories(account.some)(account.toVcsRepositoryOwner).compile.toList + } yield (written, foundRepos) + test.map { result => + val (written, foundRepos) = result + assert( + written.filter(_ === 1).size === written.size, + "Not all test repository data was not written to database!" + ) + assertEquals(foundRepos.size, expectedRepoList.size) + // We sort again because database sorting might differ slightly from code sorting. + assertEquals(foundRepos.sortBy(_.name), expectedRepoList) + } + case _ => fail("Could not generate data samples!") + } + } + + test("listRepositories must return only public repositories for any user") { + (genValidAccount.sample, genValidVcsRepositories.sample, genValidAccount.sample) match { + case (Some(account), Some(repositories), Some(otherAccount)) => + val vcsRepositories = repositories.map(_.copy(owner = account.toVcsRepositoryOwner)) + val expectedRepoList = vcsRepositories.filter(_.isPrivate === false).sortBy(_.name) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieVcsMetadataRepository[IO](tx) + val test = for { + _ <- createAccount(account, PasswordHash("I am not a password hash!"), None, None) + written <- vcsRepositories.traverse(vcsRepository => repo.createVcsRepository(vcsRepository)) + foundRepos <- repo.listRepositories(otherAccount.some)(account.toVcsRepositoryOwner).compile.toList + } yield (written, foundRepos) + test.map { result => + val (written, foundRepos) = result + assert( + written.filter(_ === 1).size === written.size, + "Not all test repository data was not written to database!" + ) + assertEquals(foundRepos.size, expectedRepoList.size) + // We sort again because database sorting might differ slightly from code sorting. + assertEquals(foundRepos.sortBy(_.name), expectedRepoList) + } + case _ => fail("Could not generate data samples!") + } + } +} 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-02-02 16:56:14.778979205 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/Generators.scala 2025-02-02 16:56:14.782979214 +0000 @@ -13,6 +13,7 @@ import java.time._ import java.util.{ Locale, UUID } +import cats.syntax.all._ import de.smederee.security.{ PrivateKey, SignAndValidate } import org.scalacheck._ @@ -95,6 +96,10 @@ given Arbitrary[Account] = Arbitrary(genValidAccount) + val genValidAccounts: Gen[List[Account]] = Gen + .nonEmptyListOf(genValidAccount) + .suchThat(accounts => accounts.size === accounts.map(_.name).distinct.size) // Ensure unique names. + val genValidSession: Gen[Session] = for { id <- genSessionId @@ -152,4 +157,18 @@ ) .map(cs => VcsRepositoryName(cs.take(64).mkString)) + val genValidVcsRepositoryOwner = for { + uid <- genUserId + name <- genValidUsername + } yield VcsRepositoryOwner(uid, name) + + val genValidVcsRepository: Gen[VcsRepository] = for { + name <- genValidVcsRepositoryName + owner <- genValidVcsRepositoryOwner + isPrivate <- Gen.oneOf(List(false, true)) + description <- Gen.alphaNumStr.map(VcsRepositoryDescription.from) + } yield VcsRepository(name, owner, isPrivate, description, None) + + val genValidVcsRepositories: Gen[List[VcsRepository]] = Gen.nonEmptyListOf(genValidVcsRepository) + } diff -rN -u old-smederee/modules/hub/src/main/resources/assets/css/main.css new-smederee/modules/hub/src/main/resources/assets/css/main.css --- old-smederee/modules/hub/src/main/resources/assets/css/main.css 2025-02-02 16:56:14.778979205 +0000 +++ new-smederee/modules/hub/src/main/resources/assets/css/main.css 2025-02-02 16:56:14.782979214 +0000 @@ -38,26 +38,26 @@ .alert { border: 1px solid; border-radius: 0.375rem; + color: white; padding: 5px; position: relative; + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); } -.alert-danger { - color: #842029; - background-color: #f8d7da; - border-color: #f5c2c7; +.alert-error { + background: rgb(202, 60, 60); } -.alert-primary { - color: #ffffff; - background-color: #1f8dd6; - border-color: #1f8dd6; +.alert-secondary { + background: rgb(66, 184, 221); +} + +.alert-success { + background: rgb(28, 184, 65); } .alert-warning { - color: #664d03; - background-color: #fff3cd; - border-color: #ffecb5; + background: rgb(223, 117, 20); } .home-menu { 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-02-02 16:56:14.778979205 +0000 +++ new-smederee/modules/hub/src/main/resources/db/migration/V2__repository_tables.sql 2025-02-02 16:56:14.782979214 +0000 @@ -3,7 +3,7 @@ "id" BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, "name" CHARACTER VARYING(64) NOT NULL, "owner" UUID NOT NULL, - "is_private" BOOLEAN DEFAULT FALSE, + "is_private" BOOLEAN NOT NULL DEFAULT FALSE, "description" CHARACTER VARYING(254), "website" TEXT, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, 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-02-02 16:56:14.778979205 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala 2025-02-02 16:56:14.782979214 +0000 @@ -361,3 +361,13 @@ a.uid === b.uid && a.name === b.name && a.email === b.email } } + +extension (account: Account) { + + /** Create vcs repository owner metadata from the account. + * + * @return + * Descriptive information about the owner of a vcs repository based on the account. + */ + def toVcsRepositoryOwner: VcsRepositoryOwner = VcsRepositoryOwner(account.uid, account.name) +} 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 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala 2025-02-02 16:56:14.782979214 +0000 @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2022 Wegtam GmbH + * + * Business Source License 1.1 - See file LICENSE for details! + * + * Change Date: 2025-06-21 + * Change License: GNU AFFERO GENERAL PUBLIC LICENSE Version 3 + */ + +package de.smederee.hub + +import java.util.UUID + +import cats.effect._ +import cats.syntax.all._ +import de.smederee.hub.VcsMetadataRepositoriesOrdering._ +import doobie._ +import doobie.Fragments._ +import doobie.implicits._ +import doobie.postgres.implicits._ +import fs2.Stream +import org.http4s.Uri + +final class DoobieVcsMetadataRepository[F[_]: Sync](tx: Transactor[F]) extends VcsMetadataRepository[F] { + + given Meta[VcsRepositoryName] = Meta[String].timap(VcsRepositoryName.apply)(_.toString) + given Meta[VcsRepositoryDescription] = Meta[String].timap(VcsRepositoryDescription.apply)(_.toString) + given Meta[Uri] = Meta[String].timap(Uri.unsafeFromString)(_.toString) + given Meta[UserId] = Meta[UUID].timap(UserId.apply)(_.toUUID) + given Meta[Username] = Meta[String].timap(Username.apply)(_.toString) + + private val selectRepositoryColumns = + fr"""SELECT "repos".name AS name, "accounts".uid AS owner_id, "accounts".name AS owner_name, "repos".is_private AS is_private, "repos".description AS description, "repos".website AS website FROM "repositories" AS "repos" JOIN "accounts" ON "repos".owner = "accounts".uid""" + + override def createVcsRepository(repository: VcsRepository): F[Int] = + sql"""INSERT INTO "repositories" (name, owner, is_private, description, website, created_at, updated_at) VALUES (${repository.name}, ${repository.owner.uid}, ${repository.isPrivate}, ${repository.description}, ${repository.website}, NOW(), NOW())""".update.run + .transact(tx) + + override def findVcsRepository( + owner: VcsRepositoryOwner, + name: VcsRepositoryName + ): F[Option[VcsRepository]] = { + val nameFilter = fr""""repos".name = $name""" + val ownerFilter = fr""""repos".owner = ${owner.uid}""" + val query = selectRepositoryColumns ++ whereAnd(ownerFilter, nameFilter) ++ fr"""LIMIT 1""" + query.query[VcsRepository].option.transact(tx) + } + + override def findVcsRepositoryId(owner: VcsRepositoryOwner, name: VcsRepositoryName): F[Option[Long]] = { + val nameFilter = fr"""name = $name""" + val ownerFilter = fr"""owner = ${owner.uid}""" + val query = fr"""SELECT id FROM "repositories"""" ++ whereAnd(ownerFilter, nameFilter) ++ fr"""LIMIT 1""" + query.query[Long].option.transact(tx) + } + + override def findVcsRepositoryOwner(name: Username): F[Option[VcsRepositoryOwner]] = + sql"""SELECT uid, name FROM "accounts" WHERE name = $name LIMIT 1""" + .query[VcsRepositoryOwner] + .option + .transact(tx) + + override def listAllRepositories( + requester: Option[Account] + )(ordering: VcsMetadataRepositoriesOrdering): Stream[F, VcsRepository] = { + val orderClause = ordering match { + case NameAscending => fr"""ORDER BY name ASC""" + case NameDescending => fr"""ORDER BY name DESC""" + } + // We use a SQL UNION here which first queries all others repos and then (potentially) the repos of the requester. + val query = requester.fold(selectRepositoryColumns ++ fr"""WHERE is_private = FALSE""")(account => + selectRepositoryColumns ++ fr"""WHERE is_private = FALSE AND owner != ${account.uid}""" ++ fr"""UNION""" ++ selectRepositoryColumns ++ fr"""WHERE owner = ${account.uid}""" + ) ++ orderClause + query.query[VcsRepository].stream.transact(tx) + } + + override def listRepositories( + requester: Option[Account] + )(owner: VcsRepositoryOwner): Stream[F, VcsRepository] = { + val ownerFilter = fr""""repos".owner = ${owner.uid}""" + val whereClause = requester match { + case None => whereAnd(ownerFilter, fr""""repos".is_private = FALSE""") // Guest only see public repos. + case Some(account) => + if (account.uid === owner.uid) + whereAnd(ownerFilter) // The user asks for their own repositories. + else + whereAnd(ownerFilter, fr""""repos".is_private = FALSE""") // TODO More logic later (groups, perms). + } + val query = selectRepositoryColumns ++ whereClause ++ fr"""ORDER BY name ASC""" + query.query[VcsRepository].stream.transact(tx) + } + +} 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-02-02 16:56:14.778979205 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala 2025-02-02 16:56:14.782979214 +0000 @@ -101,16 +101,17 @@ authenticationRepo, signAndValidate ) - signUpRepo = new DoobieSignupRepository[IO](transactor) - signUpRoutes = new SignupRoutes[IO](configuration.service.signup, signUpRepo) - landingPages = new LandingPageRoutes[IO]() - vcsRepoRoutes = new VcsRepositoryRoutes[IO](configuration.service.darcs, darcsWrapper) + signUpRepo = new DoobieSignupRepository[IO](transactor) + signUpRoutes = new SignupRoutes[IO](configuration.service.signup, signUpRepo) + landingPages = new LandingPageRoutes[IO]() + vcsMetadataRepo = new DoobieVcsMetadataRepository[IO](transactor) + vcsRepoRoutes = new VcsRepositoryRoutes[IO](configuration.service.darcs, darcsWrapper, vcsMetadataRepo) protectedRoutesWithFallThrough = authenticationWithFallThrough( authenticationRoutes.protectedRoutes <+> signUpRoutes.protectedRoutes <+> vcsRepoRoutes.protectedRoutes <+> landingPages.protectedRoutes ) globalRoutes = Router( Constants.assetsPath.path.toAbsolute.toString -> assetsRoutes, - "/" -> (protectedRoutesWithFallThrough <+> authenticationRoutes.routes <+> signUpRoutes.routes <+> landingPages.routes) + "/" -> (protectedRoutesWithFallThrough <+> authenticationRoutes.routes <+> signUpRoutes.routes <+> vcsRepoRoutes.routes <+> landingPages.routes) ).orNotFound resource = EmberServerBuilder .default[IO] diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/NewVcsRepositoryForm.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/NewVcsRepositoryForm.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/NewVcsRepositoryForm.scala 2025-02-02 16:56:14.778979205 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/NewVcsRepositoryForm.scala 2025-02-02 16:56:14.782979214 +0000 @@ -53,25 +53,33 @@ data.get(fieldIsPrivate).fold(false.validNec)(s => s.matches("true").validNec) val description = data .get(fieldDescription) - .fold(Option.empty[VcsRepositoryDescription].validNec)(s => - VcsRepositoryDescription - .from(s) - .fold(FormFieldError("Invalid repository description!").invalidNec)(descr => Option(descr).validNec) - ) + .fold(Option.empty[VcsRepositoryDescription].validNec) { s => + if (s.trim.isEmpty) + Option.empty[VcsRepositoryDescription].validNec // Sometimes "empty" strings are sent. + else + VcsRepositoryDescription + .from(s) + .fold(FormFieldError("Invalid repository description!").invalidNec)(descr => + Option(descr).validNec + ) + } .leftMap(es => NonEmptyChain.of(Map(fieldDescription -> es.toList))) val website = data .get(fieldWebsite) - .fold(Option.empty[Uri].validNec)(s => - Uri - .fromString(s) - .toOption - .fold(FormFieldError("Invalid website URI!").invalidNec) { uri => - uri.scheme match { - case Some(Uri.Scheme.http) | Some(Uri.Scheme.https) => Option(uri).validNec - case _ => FormFieldError("Invalid website URI!").invalidNec + .fold(Option.empty[Uri].validNec) { s => + if (s.trim.isEmpty) + Option.empty[Uri].validNec // Sometimes "empty" strings are sent. + else + Uri + .fromString(s) + .toOption + .fold(FormFieldError("Invalid website URI!").invalidNec) { uri => + uri.scheme match { + case Some(Uri.Scheme.http) | Some(Uri.Scheme.https) => Option(uri).validNec + case _ => FormFieldError("Invalid website URI!").invalidNec + } } - } - ) + } .leftMap(es => NonEmptyChain.of(Map(fieldWebsite -> es.toList))) (name, privateFlag, description, website).mapN { case (validName, isPrivate, validDescription, validWebsite) => 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 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala 2025-02-02 16:56:14.782979214 +0000 @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2022 Wegtam GmbH + * + * Business Source License 1.1 - See file LICENSE for details! + * + * Change Date: 2025-06-21 + * Change License: GNU AFFERO GENERAL PUBLIC LICENSE Version 3 + */ + +package de.smederee.hub + +import fs2.Stream + +/** A base class for a database repository that should handle all functionality regarding vcs repositories and + * their metadata in the database. + * + * @tparm + * F A higher kinded type which wraps the actual return values. + */ +abstract class VcsMetadataRepository[F[_]] { + + /** Create a database entry for the given vcs repository. + * + * @param repository + * The vcs repository metadata that shall be written to the database. + * @return + * The number of affected database rows. + */ + def createVcsRepository(repository: VcsRepository): F[Int] + + /** Search for the vcs repository entry with the given owner and name. + * + * @param owner + * Data about the owner of the repository containing information needed to query the database. + * @param name + * The repository name which must be unique in regard to the owner. + * @return + * An option to the successfully found vcs repository entry. + */ + def findVcsRepository(owner: VcsRepositoryOwner, name: VcsRepositoryName): F[Option[VcsRepository]] + + /** Search for the internal database specific (auto generated) ID of the given owner / repository + * combination which serves as a primary key for the database table. + * + * @param owner + * Data about the owner of the repository containing information needed to query the database. + * @param name + * The repository name which must be unique in regard to the owner. + * @return + * An option to the internal database ID. + */ + def findVcsRepositoryId(owner: VcsRepositoryOwner, name: VcsRepositoryName): F[Option[Long]] + + /** Search for a repository owner of whom we only know the name. + * + * @param name + * The name of the repository owner which is the username of the actual owners account. + * @return + * An option to successfully found repository owner. + */ + def findVcsRepositoryOwner(name: Username): F[Option[VcsRepositoryOwner]] + + /** Return a list of all repositories from all users. + * + * @param requester + * An optional account (None if guest i.e. not logged in user) for whom the list shall be created. This + * will affect which repositories will be returned in regard to access rights. + * @param ordering + * The desired ordering of the list. + * @return + * A stream of vcs repository entries in the requested order. + */ + def listAllRepositories(requester: Option[Account])( + ordering: VcsMetadataRepositoriesOrdering + ): Stream[F, VcsRepository] + + /** Return a list of all repositories of the given owner. + * + * @param requester + * An optional account (None if guest i.e. not logged in user) for whom the list shall be created. This + * will affect which repositories will be returned in regard to access rights. + * @param owner + * Data about the owner of the repositories containing information needed to query the database. + * @return + * A stream of vcs repository entries ordered by name which may be empty. + */ + def listRepositories(requester: Option[Account])(owner: VcsRepositoryOwner): Stream[F, VcsRepository] + +} + +/** Helper types to provide sorting instructions to several functions. + */ +enum VcsMetadataRepositoriesOrdering { + case NameAscending extends VcsMetadataRepositoriesOrdering + case NameDescending extends VcsMetadataRepositoriesOrdering +} 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-02-02 16:56:14.778979205 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala 2025-02-02 16:56:14.782979214 +0000 @@ -33,11 +33,16 @@ * The configuration for darcs related operations. * @param darcs * A class providing darcs VCS operations. + * @param vcsMetadataRepo + * 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 VcsRepositoryRoutes[F[_]: Async](config: DarcsConfiguration, darcs: DarcsCommands[F]) - extends Http4sDsl[F] { +final class VcsRepositoryRoutes[F[_]: Async]( + config: DarcsConfiguration, + darcs: DarcsCommands[F], + vcsMetadataRepo: VcsMetadataRepository[F] +) extends Http4sDsl[F] { private val log = LoggerFactory.getLogger(getClass) private val createRepoPath = uri"/repo/create" @@ -105,16 +110,47 @@ directory <- Sync[F].delay( Paths.get(config.repositoriesDirectory.toPath.toString, user.name.toString) ) - _ <- Sync[F].delay { - if (!Files.exists(directory)) { - log.debug(s"User repository directory does not exist, trying to create it: $directory") - Files.createDirectories(directory) - } + repoInDb <- vcsMetadataRepo.findVcsRepository( + user.toVcsRepositoryOwner, + newVcsRepository.name + ) + output <- repoInDb match { + case None => + for { + _ <- Sync[F].delay { + if (repoInDb.isEmpty && !Files.exists(directory)) { + log.debug( + s"User repository directory does not exist, trying to create it: $directory" + ) + Files.createDirectories(directory) + } + } + repoMetadata = VcsRepository( + newVcsRepository.name, + user.toVcsRepositoryOwner, + newVcsRepository.isPrivate, + newVcsRepository.description, + newVcsRepository.website + ) + output <- darcs.initialize(directory)(newVcsRepository.name.toString)(Chain.empty) + _ <- + if (output.exitValue === 0) + vcsMetadataRepo.createVcsRepository(repoMetadata) + else + Sync[F].pure(0) // Do not create DB entry if darcs init failed! + } yield output + case Some(_) => + Sync[F].delay(DarcsCommandOutput(1, Chain.empty, Chain("The repository already exists!"))) } - output <- darcs.initialize(directory)(newVcsRepository.name.toString)(Chain.empty) resp <- output.exitValue match { case 0 => - SeeOther.apply(Location(Uri(path = Uri.Path.Root))) + SeeOther.apply( + Location( + Uri(path = + Uri.Path.Root |+| Uri.Path(Vector(Uri.Path.Segment(s"~${user.name.toString}"))) + ) + ) + ) case _ => for { _ <- Sync[F].delay( @@ -133,6 +169,36 @@ } } + private val showAllRepositories: AuthedRoutes[Account, F] = AuthedRoutes.of { + case ar @ GET -> Root / "projects" as user => + for { + csrf <- Sync[F].delay(ar.req.getCsrfToken) + repos <- vcsMetadataRepo + .listAllRepositories(user.some)(VcsMetadataRepositoriesOrdering.NameAscending) + .compile + .toList + actionBaseUri <- Sync[F].delay(uri"projects") + resp <- Ok.apply( + views.html.showAllRepositories()(actionBaseUri, csrf, s"Smederee - Projects".some, user.some)(repos) + ) + } yield resp + } + + private val showAllRepositoriesForGuests: HttpRoutes[F] = HttpRoutes.of { + case req @ GET -> Root / "projects" => + for { + csrf <- Sync[F].delay(req.getCsrfToken) + repos <- vcsMetadataRepo + .listAllRepositories(None)(VcsMetadataRepositoriesOrdering.NameAscending) + .compile + .toList + actionBaseUri <- Sync[F].delay(uri"projects") + resp <- Ok.apply( + views.html.showAllRepositories()(actionBaseUri, csrf, s"Smederee - Projects".some, None)(repos) + ) + } yield resp + } + private val showCreateRepositoryForm: AuthedRoutes[Account, F] = AuthedRoutes.of { case ar @ GET -> Root / "repo" / "create" as user => for { @@ -145,38 +211,38 @@ } private val showRepositories: AuthedRoutes[Account, F] = AuthedRoutes.of { - case ar @ GET -> Root / UsernamePathParameter(repositoriesOwner) as user => + case ar @ GET -> Root / UsernamePathParameter(repositoriesOwnerName) as user => for { - csrf <- Sync[F].delay(ar.req.getCsrfToken) - directory <- Sync[F].delay( - os.Path( - Paths.get( - config.repositoriesDirectory.toPath.toString, - repositoriesOwner.toString - ) - ) - ) - listing <- Sync[F].delay( - os.walk - .attrs(directory, skip = (path, _) => !os.isDir(path), maxDepth = 1) - .map((path, attrs) => (path.relativeTo(directory), attrs)) - .sortBy(_._1) - ) + csrf <- Sync[F].delay(ar.req.getCsrfToken) + owner <- vcsMetadataRepo.findVcsRepositoryOwner(repositoriesOwnerName) + repos <- owner.traverse(owner => vcsMetadataRepo.listRepositories(user.some)(owner).compile.toList) actionBaseUri <- Sync[F].delay( Uri(path = Uri.Path.Root |+| Uri.Path( - Vector(Uri.Path.Segment(s"~$repositoriesOwner")) + Vector(Uri.Path.Segment(s"~$repositoriesOwnerName")) ) ) ) - resp <- Ok.apply( - views.html.showRepositories()( - actionBaseUri, - csrf, - s"Smederee/~$repositoriesOwner".some, - user - )(listing, repositoriesOwner) - ) + resp <- owner match { + case None => // TODO Better error message... + NotFound.apply( + views.html.showRepositories()( + actionBaseUri, + csrf, + s"Smederee/~$repositoriesOwnerName".some, + user + )(repos.getOrElse(List.empty), repositoriesOwnerName) + ) + case Some(_) => + Ok.apply( + views.html.showRepositories()( + actionBaseUri, + csrf, + s"Smederee/~$repositoriesOwnerName".some, + user + )(repos.getOrElse(List.empty), repositoriesOwnerName) + ) + } } yield resp } @@ -334,6 +400,8 @@ } val protectedRoutes = - showRepositories <+> parseCreateRepositoryForm <+> showCreateRepositoryForm <+> showRepositoryHistory <+> showRepositoryFiles <+> showRepository + showAllRepositories <+> showRepositories <+> parseCreateRepositoryForm <+> showCreateRepositoryForm <+> showRepositoryHistory <+> showRepositoryFiles <+> showRepository + + val routes = showAllRepositoriesForGuests } 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-02-02 16:56:14.778979205 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala 2025-02-02 16:56:14.782979214 +0000 @@ -9,6 +9,7 @@ package de.smederee.hub +import cats._ import cats.data._ import cats.syntax.all._ import org.http4s.Uri @@ -42,6 +43,13 @@ opaque type VcsRepositoryName = String object VcsRepositoryName { + given Eq[VcsRepositoryName] = Eq.fromUniversalEquals + + given Order[VcsRepositoryName] = 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[VcsRepositoryName] = implicitly[Order[VcsRepositoryName]].toOrdering + val Format: Regex = "^[a-zA-Z0-9][a-zA-Z0-9\\-_]{1,63}$".r /** Create an instance of VcsRepositoryName from the given String type. @@ -101,6 +109,21 @@ def unapply(str: String): Option[VcsRepositoryName] = Option(str).flatMap(VcsRepositoryName.from) } +/** Descriptive information about the owner of a vcs repository. + * + * @param owner + * The user ID of the account that owns the repository. + * @param name + * The name of the account that owns the repository. + */ +final case class VcsRepositoryOwner(uid: UserId, name: Username) + +object VcsRepositoryOwner { + + given Eq[VcsRepositoryOwner] = Eq.fromUniversalEquals + +} + /** Data about a VCS respository. * * @param name @@ -108,7 +131,7 @@ * alphanumeric ASCII characters as well as minus or underscore signs. It must be between 2 and 64 * characters long. * @param owner - * The account that owns the repository. + * The owner of the repository. * @param isPrivate * A flag indicating if this repository is private i.e. only visible / accessible for accounts with * appropriate permissions. @@ -119,8 +142,12 @@ */ final case class VcsRepository( name: VcsRepositoryName, - owner: Account, + owner: VcsRepositoryOwner, isPrivate: Boolean, description: Option[VcsRepositoryDescription], website: Option[Uri] ) + +object VcsRepository { + given Eq[VcsRepository] = Eq.fromUniversalEquals +} 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-02-02 16:56:14.778979205 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/createRepository.scala.html 2025-02-02 16:56:14.782979214 +0000 @@ -9,7 +9,7 @@ <div class="form-errors"> @formErrors.get(fieldGlobal).map { es => @for(error <- es) { - <p class="alert alert-danger"> + <p class="alert alert-error"> <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span> <span class="sr-only">Fehler:</span> @error 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-02-02 16:56:14.778979205 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/login.scala.html 2025-02-02 16:56:14.782979214 +0000 @@ -9,7 +9,7 @@ <div class="form-errors"> @formErrors.get(fieldGlobal).map { es => @for(error <- es) { - <p class="alert alert-danger"> + <p class="alert alert-error"> <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span> <span class="sr-only">Fehler:</span> @error 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-02-02 16:56:14.778979205 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html 2025-02-02 16:56:14.782979214 +0000 @@ -4,6 +4,7 @@ <a class="logo pure-menu-heading" href="@pathPrefix/">@Messages("global.navbar.top.logo")</a> <ul class="pure-menu-list"> + <li class="pure-menu-item"><a class="pure-menu-link" href="@pathPrefix/projects">Projects</a></li> @if(user.nonEmpty) { @for(account <- user) { <li class="pure-menu-item"><a class="pure-menu-link" href="@pathPrefix/~@account.name">Your repositories</a></li> 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 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showAllRepositories.scala.html 2025-02-02 16:56:14.782979214 +0000 @@ -0,0 +1,37 @@ +@(lang: LanguageCode = LanguageCode("en"), pathPrefix: Option[Uri] = None)(actionBaseUri: Uri, csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Option[Account])(listing: List[VcsRepository]) +@main(lang, pathPrefix)()(csrf, title, user) { +@defining(lang.toLocale) { implicit locale => + <div class="content"> + <div class="pure-g"> + <div class="l-box pure-u-1-1 pure-u-md-1-1"> + @if(listing.nonEmpty) { + <table class="pure-table pure-table-horizontal"> + <thead> + <tr> + <th></th> + <th>Name</th> + <th>Description</th> + </tr> + </thead> + <tbody> + @for(repo <- listing) { + <tr> + <td><i class="fa-solid fa-folder-tree"></i> @if(repo.isPrivate){<i class="fa-solid fa-lock"></i>}else{ }</td> + <td> + <a href="@createFullPath(pathPrefix)(Uri(path = Uri.Path.Root).addSegment(s"~${repo.owner.name.toString}"))">~@{repo.owner.name}</a> + / + <a href="@createFullPath(pathPrefix)(Uri(path = Uri.Path.Root).addSegment(s"~${repo.owner.name.toString}").addSegment(repo.name.toString))">@{repo.name}</a> + </td> + <td>@repo.description</td> + </tr> + } + </tbody> + </table> + } else { + <div class="alert alert-warning">No repositories found.</div> + } + </div> + </div> + </div> +} +} 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-02-02 16:56:14.782979214 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositories.scala.html 2025-02-02 16:56:14.782979214 +0000 @@ -1,29 +1,31 @@ -@(lang: LanguageCode = LanguageCode("en"), pathPrefix: Option[Uri] = None)(actionBaseUri: Uri, csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account)(listing: IndexedSeq[(os.RelPath, os.StatInfo)], repositoriesOwner: Username) +@(lang: LanguageCode = LanguageCode("en"), pathPrefix: Option[Uri] = None)(actionBaseUri: Uri, csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account)(listing: List[VcsRepository], repositoriesOwner: Username) @main(lang, pathPrefix)()(csrf, title, user.some) { @defining(lang.toLocale) { implicit locale => <div class="content"> <div class="pure-g"> <div class="l-box pure-u-1-1 pure-u-md-1-1"> + @if(listing.nonEmpty) { <table class="pure-table pure-table-horizontal"> <thead> <tr> <th></th> <th>Name</th> - <th>Size</th> - <th>Modified</th> + <th>Description</th> </tr> </thead> <tbody> - @for(entry <- listing) { + @for(repo <- listing) { <tr> - <td><i class="fa-solid fa-folder-tree"></i></td> - <td><a href="@createFullPath(pathPrefix)(actionBaseUri.addSegment(entry._1.last))">@{entry._1}</a></td> - <td>@{entry._2.size}</td> - <td>@{entry._2.mtime}</td> + <td><i class="fa-solid fa-folder-tree"></i> @if(repo.isPrivate){<i class="fa-solid fa-lock"></i>}else{ }</td> + <td><a href="@createFullPath(pathPrefix)(actionBaseUri.addSegment(repo.name.toString))">@{repo.name}</a></td> + <td>@repo.description</td> </tr> } </tbody> </table> + } else { + <div class="alert">No repositories found.</div> + } </div> </div> </div> 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-02-02 16:56:14.782979214 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/signup.scala.html 2025-02-02 16:56:14.782979214 +0000 @@ -9,7 +9,7 @@ <div class="form-errors"> @formErrors.get(fieldGlobal).map { es => @for(error <- es) { - <p class="alert alert-danger"> + <p class="alert alert-error"> <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span> <span class="sr-only">Fehler:</span> @error 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-02-02 16:56:14.782979214 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala 2025-02-02 16:56:14.782979214 +0000 @@ -13,6 +13,7 @@ import java.time._ import java.util.{ Locale, UUID } +import cats.syntax.all._ import de.smederee.security.{ PrivateKey, SignAndValidate } import org.scalacheck._ @@ -95,6 +96,10 @@ given Arbitrary[Account] = Arbitrary(genValidAccount) + val genValidAccounts: Gen[List[Account]] = Gen + .nonEmptyListOf(genValidAccount) + .suchThat(accounts => accounts.size === accounts.map(_.name).distinct.size) // Ensure unique names. + val genValidSession: Gen[Session] = for { id <- genSessionId @@ -152,4 +157,18 @@ ) .map(cs => VcsRepositoryName(cs.take(64).mkString)) + val genValidVcsRepositoryOwner = for { + uid <- genUserId + name <- genValidUsername + } yield VcsRepositoryOwner(uid, name) + + val genValidVcsRepository: Gen[VcsRepository] = for { + name <- genValidVcsRepositoryName + owner <- genValidVcsRepositoryOwner + isPrivate <- Gen.oneOf(List(false, true)) + description <- Gen.alphaNumStr.map(VcsRepositoryDescription.from) + } yield VcsRepository(name, owner, isPrivate, description, None) + + val genValidVcsRepositories: Gen[List[VcsRepository]] = Gen.nonEmptyListOf(genValidVcsRepository) + }