~jan0sch/smederee
Showing details for patch 286722a69f64a614c6d4cdd56433b54277aa2bdc.
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-02-02 03:51:09.989477878 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala 2025-02-02 03:51:09.989477878 +0000 @@ -24,6 +24,7 @@ import de.smederee.hub.config.SmedereeHubConfig import doobie._ import org.flywaydb.core.Flyway +import org.http4s.implicits._ import munit._ @@ -231,7 +232,7 @@ val (written, foundRepos) = result assert( written.filter(_ === 1).size === written.size, - "Not all test repository data was not written to database!" + "Not all test repository data was written to database!" ) assertEquals(foundRepos.size, expectedRepoList.size) } @@ -268,7 +269,7 @@ val (written, foundRepos) = result assert( written.filter(_ === 1).size === written.size, - "Not all test repository data was not written to database!" + "Not all test repository data was written to database!" ) assertEquals(foundRepos.size, expectedRepoList.size) } @@ -308,7 +309,7 @@ val (written, foundRepos) = result assert( written.filter(_ === 1).size === written.size, - "Not all test repository data was not written to database!" + "Not all test repository data was written to database!" ) assertEquals(foundRepos.size, expectedRepoList.size) } @@ -334,7 +335,7 @@ val (written, foundRepos) = result assert( written.filter(_ === 1).size === written.size, - "Not all test repository data was not written to database!" + "Not all test repository data was written to database!" ) assertEquals(foundRepos.size, expectedRepoList.size) // We sort again because database sorting might differ slightly from code sorting. @@ -361,7 +362,7 @@ val (written, foundRepos) = result assert( written.filter(_ === 1).size === written.size, - "Not all test repository data was not written to database!" + "Not all test repository data was written to database!" ) assertEquals(foundRepos.size, expectedRepoList.size) // We sort again because database sorting might differ slightly from code sorting. @@ -388,7 +389,7 @@ val (written, foundRepos) = result assert( written.filter(_ === 1).size === written.size, - "Not all test repository data was not written to database!" + "Not all test repository data was written to database!" ) assertEquals(foundRepos.size, expectedRepoList.size) // We sort again because database sorting might differ slightly from code sorting. @@ -396,5 +397,40 @@ } case _ => fail("Could not generate data samples!") } + } + + test("updateVcsRepository must update all columns correctly") { + (genValidAccount.sample, genValidVcsRepositories.sample) match { + case (Some(account), Some(repository :: repositories)) => + val vcsRepositories = repository.copy(owner = account.toVcsRepositoryOwner) :: repositories.map( + _.copy(owner = account.toVcsRepositoryOwner) + ) + val updatedRepo = repository + .copy(owner = account.toVcsRepositoryOwner) + .copy( + isPrivate = !repository.isPrivate, + description = Option(VcsRepositoryDescription("I am a description...")), + website = Option(uri"https://updated.example.com") + ) + 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)) + updated <- repo.updateVcsRepository(updatedRepo) + persisted <- repo.findVcsRepository(account.toVcsRepositoryOwner, repository.name) + } yield (written, updated, persisted) + test.map { result => + val (written, updated, persisted) = result + assert( + written.filter(_ === 1).size === written.size, + "Not all test repository data was written to database!" + ) + assert(updated === 1, "Repository was not updated in database!") + assert(persisted === Some(updatedRepo)) + } + case _ => fail("Could not generate data samples!") + } } } diff -rN -u old-smederee/modules/hub/src/main/resources/messages_en.properties new-smederee/modules/hub/src/main/resources/messages_en.properties --- old-smederee/modules/hub/src/main/resources/messages_en.properties 2025-02-02 03:51:09.989477878 +0000 +++ new-smederee/modules/hub/src/main/resources/messages_en.properties 2025-02-02 03:51:09.989477878 +0000 @@ -32,6 +32,20 @@ form.create-repo.website=Website form.create-repo.website.placeholder=https://example.com form.create-repo.website.help=An optional URI pointing to the website of your project. +form.repository.delete.button.submit=Delete repository +form.repository.delete.i-am-sure=Yes, I am sure that I want to delete the repository {0}! +form.repository.delete.notice=This action CANNOT be undone! Please be careful. +form.repository.delete.title=Delete repository {0} +form.edit-repo.button.submit=Edit repository +form.edit-repo.name=Name +form.edit-repo.name.help=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. +form.edit-repo.is-private=Private Repository +form.edit-repo.is-private.help=A private repository can only be accessed by the owner and accounts which have been given permissions to do so. +form.edit-repo.description=Description +form.edit-repo.description.help=An optional short description of you repo / project. +form.edit-repo.website=Website +form.edit-repo.website.placeholder=https://example.com +form.edit-repo.website.help=An optional URI pointing to the website of your project. form.fork.button.submit=Clone to your account. form.login.button.submit=Login form.login.password=Password @@ -140,6 +154,8 @@ repository.menu.changes.next=Next repository.menu.changes=Changes +repository.menu.delete=Delete +repository.menu.edit=Edit repository.menu.files=Files repository.menu.overview=Overview repository.menu.website=Website 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-02-02 03:51:09.989477878 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala 2025-02-02 03:51:09.989477878 +0000 @@ -45,6 +45,10 @@ sql"""INSERT INTO "repositories" (name, owner, is_private, description, vcs_type, website, created_at, updated_at) VALUES (${repository.name}, ${repository.owner.uid}, ${repository.isPrivate}, ${repository.description}, ${repository.vcsType}, ${repository.website}, NOW(), NOW())""".update.run .transact(tx) + override def deleteVcsRepository(repository: VcsRepository): F[Int] = + sql"""DELETE FROM "repositories" WHERE owner = ${repository.owner.uid} AND name = ${repository.name}""".update.run + .transact(tx) + override def findVcsRepository( owner: VcsRepositoryOwner, name: VcsRepositoryName @@ -98,4 +102,11 @@ query.query[VcsRepository].stream.transact(tx) } + override def updateVcsRepository(repository: VcsRepository): F[Int] = + sql"""UPDATE "repositories" SET is_private = ${repository.isPrivate}, + description = ${repository.description}, + website = ${repository.website}, + updated_at = NOW() + WHERE owner = ${repository.owner.uid} AND name = ${repository.name}""".update.run.transact(tx) + } diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/EditVcsRepositoryForm.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/EditVcsRepositoryForm.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/EditVcsRepositoryForm.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/EditVcsRepositoryForm.scala 2025-02-02 03:51:09.989477878 +0000 @@ -0,0 +1,136 @@ +/* + * 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 cats.data._ +import cats.syntax.all._ +import de.smederee.hub.forms._ +import de.smederee.hub.forms.types._ +import org.http4s.Uri + +/** Data container for the form to edit / customise a VCS repository. + * + * @param name + * 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. + * @param isPrivate + * A flag indicating if this repository is private i.e. only visible / accessible for accounts with + * appropriate permissions. + * @param description + * An optional short text description of the repository. + * @param website + * An optional uri pointing to a website related to the repository / project. + */ +final case class EditVcsRepositoryForm( + name: VcsRepositoryName, + isPrivate: Boolean, + description: Option[VcsRepositoryDescription], + website: Option[Uri] +) + +object EditVcsRepositoryForm extends FormValidator[EditVcsRepositoryForm] { + val fieldDescription: FormField = FormField("description") + val fieldIsPrivate: FormField = FormField("is_private") + val fieldName: FormField = FormField("name") + val fieldWebsite: FormField = FormField("website") + + /** Create a form for editing a vcs repository filled with the data from the given repository. + * + * @param repo + * A VCS repository object containing the data that shall be used to fill the form. + * @return + * A edit vcs repository form filled with the data from the repository. + */ + def fromVcsRepository(repo: VcsRepository): EditVcsRepositoryForm = + EditVcsRepositoryForm(repo.name, repo.isPrivate, repo.description, repo.website) + + override def validate(data: Map[String, String]): ValidatedNec[FormErrors, EditVcsRepositoryForm] = { + val name = data + .get(fieldName) + .fold(FormFieldError("No repository name given!").invalidNec)(s => + VcsRepositoryName.from(s).fold(FormFieldError("Invalid repository name!").invalidNec)(_.validNec) + ) + .leftMap(es => NonEmptyChain.of(Map(fieldName -> es.toList))) + val privateFlag: ValidatedNec[FormErrors, Boolean] = + data.get(fieldIsPrivate).fold(false.validNec)(s => s.matches("true").validNec) + val description = data + .get(fieldDescription) + .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 => + 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) => + EditVcsRepositoryForm(validName, isPrivate, validDescription, validWebsite) + } + } + +} + +extension (form: EditVcsRepositoryForm) { + + /** 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] = { + val isPrivate = + if (form.isPrivate) + "true" + else + "false" + val formData = Map( + EditVcsRepositoryForm.fieldName.toString -> form.name.toString, + EditVcsRepositoryForm.fieldIsPrivate.toString -> isPrivate + ) + val description = form.description.fold(Map.empty)(description => + Map(EditVcsRepositoryForm.fieldDescription.toString -> description.toString) + ) + val website = form.website.fold(Map.empty)(website => + Map(EditVcsRepositoryForm.fieldWebsite.toString -> website.toString) + ) + formData ++ description ++ website + } +} 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-02-02 03:51:09.989477878 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala 2025-02-02 03:51:09.989477878 +0000 @@ -36,6 +36,15 @@ */ def createVcsRepository(repository: VcsRepository): F[Int] + /** Delete the repository from the database. + * + * @param repository + * The vcs repository metadata that shall be deleted from the database. + * @return + * The number of affected database rows. + */ + def deleteVcsRepository(repository: VcsRepository): F[Int] + /** Search for the vcs repository entry with the given owner and name. * * @param owner @@ -94,6 +103,15 @@ */ def listRepositories(requester: Option[Account])(owner: VcsRepositoryOwner): Stream[F, VcsRepository] + /** Update the database entry for the given vcs repository. + * + * @param repository + * The vcs repository metadata that shall be updated within the database. + * @return + * The number of affected database rows. + */ + def updateVcsRepository(repository: VcsRepository): F[Int] + } /** Helper types to provide sorting instructions to several functions. 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 03:51:09.989477878 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala 2025-02-02 03:51:09.989477878 +0000 @@ -17,6 +17,7 @@ package de.smederee.hub +import java.io.IOException import java.nio.file._ import java.util.Locale @@ -69,6 +70,67 @@ // The base URI for our site which that be passed into some templates which create links themselfes. private val baseUri = linkConfig.createFullUri(Uri()) + /** Delete the given directory recursively. It is checked if the given directory is a direct sub directory + * of the owner's directory under `repositoriesDirectory` and only if this is the case is the directory + * removed. + * + * @param ownerName + * The name of the repository owner which is used for the directory check. + * @param repoDirectory + * The path on the filesystem to the directory that shall be deleted. + * @return + * `true` if the directory was deleted. + */ + protected def deleteRepositoryDirectory( + ownerName: Username + )(repoDirectory: java.nio.file.Path): F[Boolean] = + for { + _ <- Sync[F].delay(log.debug(s"Request to delete repository dir: $repoDirectory")) + reposDirPath <- Sync[F].delay( + configuration.darcs.repositoriesDirectory.toPath.resolve(ownerName.toString) + ) + isSubDir <- Sync[F].delay(reposDirPath.equals(repoDirectory.getParent())) + deleted <- + Sync[F].delay { + if (isSubDir) { + Files.walkFileTree( + repoDirectory, + new FileVisitor[java.nio.file.Path] { + override def visitFileFailed(file: java.nio.file.Path, exc: IOException): FileVisitResult = + FileVisitResult.CONTINUE + + override def visitFile( + file: java.nio.file.Path, + attrs: java.nio.file.attribute.BasicFileAttributes + ): FileVisitResult = { + Files.delete(file) + FileVisitResult.CONTINUE + } + + override def preVisitDirectory( + dir: java.nio.file.Path, + attrs: java.nio.file.attribute.BasicFileAttributes + ): FileVisitResult = FileVisitResult.CONTINUE + + override def postVisitDirectory( + dir: java.nio.file.Path, + exc: IOException + ): FileVisitResult = { + Files.delete(dir) + FileVisitResult.CONTINUE + } + } + ) + Files.deleteIfExists(repoDirectory) + } else { + log.warn( + s"Refused requested removal of directory $repoDirectory which is not a direct sub directory of the configured repositories directory!" + ) + false + } + } + } yield deleted + /** Logic for creating a distribution file and serving it as a download to the user. * * @param csrf @@ -646,6 +708,7 @@ sourceRepository = loadedSourceRepo.filter(r => r.isPrivate === false) // Check if a repository with that name already exists for the user. loadedTargetRepo <- vcsMetadataRepo.findVcsRepository(user.toVcsRepositoryOwner, repositoryName) + // If no repo exists we copy and adjust the source one, otherwise we return `None`. targetRepository = loadedTargetRepo.fold( sourceRepository.map(_.copy(owner = user.toVcsRepositoryOwner)) )(_ => None) @@ -791,6 +854,123 @@ } } + private val parseDeleteRepositoryForm: AuthedRoutes[Account, F] = AuthedRoutes.of { + case ar @ POST -> Root / UsernamePathParameter(repositoryOwnerName) / VcsRepositoryNamePathParameter( + repositoryName + ) / "delete" as user => + ar.req.decodeStrict[F, UrlForm] { urlForm => + for { + csrf <- Sync[F].delay(ar.req.getCsrfToken) + _ <- Sync[F].raiseUnless(user.validatedEmail)( + new Error( + "An unvalidated account is not allowed to edit a repository!" + ) // FIXME Proper error handling! + ) + owner <- vcsMetadataRepo.findVcsRepositoryOwner(repositoryOwnerName) + loadedRepo <- owner match { + case None => Sync[F].pure(None) + case Some(owner) => vcsMetadataRepo.findVcsRepository(owner, repositoryName) + } + redirectUri <- Sync[F].delay( + linkConfig.createFullUri(Uri(path = Uri.Path(Vector(Uri.Path.Segment(s"~$repositoryOwnerName"))))) + ) + // You can only delete repositories that you own! + repo = loadedRepo.filter(_.owner === user.toVcsRepositoryOwner) + _ <- Sync[F].raiseUnless(repo.nonEmpty)(new Error("Repository not found!")) + repoDir <- Sync[F].delay( + repo.map(repo => + darcsConfig.repositoriesDirectory.toPath.resolve(user.name.toString).resolve(repo.name.toString) + ) + ) + _ <- repoDir.traverse(directory => deleteRepositoryDirectory(repositoryOwnerName)(directory)) + _ <- repo.traverse(repo => vcsMetadataRepo.deleteVcsRepository(repo)) + resp <- SeeOther(Location(redirectUri)) + } yield resp + } + } + + private val parseEditRepositoryForm: AuthedRoutes[Account, F] = AuthedRoutes.of { + case ar @ POST -> Root / UsernamePathParameter(repositoryOwnerName) / VcsRepositoryNamePathParameter( + repositoryName + ) / "edit" as user => + ar.req.decodeStrict[F, UrlForm] { urlForm => + for { + csrf <- Sync[F].delay(ar.req.getCsrfToken) + _ <- Sync[F].raiseUnless(user.validatedEmail)( + new Error( + "An unvalidated account is not allowed to edit a repository!" + ) // FIXME Proper error handling! + ) + owner <- vcsMetadataRepo.findVcsRepositoryOwner(repositoryOwnerName) + loadedRepo <- owner match { + case None => Sync[F].pure(None) + case Some(owner) => vcsMetadataRepo.findVcsRepository(owner, repositoryName) + } + // TODO Replace with whatever we implement as proper permission model. ;-) + repo = loadedRepo.filter(r => r.isPrivate === false || r.owner === user.toVcsRepositoryOwner) + _ <- Sync[F].raiseUnless(repo.nonEmpty)(new Error("Repository not found!")) + editAction <- Sync[F].delay( + linkConfig.createFullUri( + Uri(path = + Uri.Path( + Vector( + Uri.Path.Segment(s"~$repositoryOwnerName"), + Uri.Path.Segment(repositoryName.toString), + Uri.Path.Segment("edit") + ) + ) + ) + ) + ) + repoUri <- Sync[F].delay( + linkConfig.createFullUri( + Uri(path = + Uri.Path( + Vector( + Uri.Path.Segment(s"~$repositoryOwnerName"), + Uri.Path.Segment(repositoryName.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)! + } + } + form <- Sync[F].delay(EditVcsRepositoryForm.validate(formData)) + resp <- form match { + case Validated.Invalid(errors) => + BadRequest( + views.html.editRepository()( + editAction, + csrf, + Option(s"~$repositoryOwnerName/$repositoryName - edit"), + user + )(formData, FormErrors.fromNec(errors)) + ) + case Validated.Valid(updatedVcsRepository) => + for { + _ <- repo.traverse { repo => + val repoMetadata = repo.copy( + isPrivate = updatedVcsRepository.isPrivate, + description = updatedVcsRepository.description, + website = updatedVcsRepository.website + ) + vcsMetadataRepo.updateVcsRepository(repoMetadata) + } + resp <- SeeOther(Location(repoUri)) + } yield resp + } + } yield resp + } + } + private val showAllRepositories: AuthedRoutes[Account, F] = AuthedRoutes.of { case ar @ GET -> Root / "projects" as user => for { @@ -825,6 +1005,90 @@ } yield resp } + private val showDeleteRepositoryForm: AuthedRoutes[Account, F] = AuthedRoutes.of { + case ar @ GET -> Root / UsernamePathParameter(repositoryOwnerName) / VcsRepositoryNamePathParameter( + repositoryName + ) / "delete" as user => + for { + csrf <- Sync[F].delay(ar.req.getCsrfToken) + owner <- vcsMetadataRepo.findVcsRepositoryOwner(repositoryOwnerName) + loadedRepo <- owner match { + case None => Sync[F].pure(None) + case Some(owner) => vcsMetadataRepo.findVcsRepository(owner, repositoryName) + } + // TODO Replace with whatever we implement as proper permission model. ;-) + repo = loadedRepo.filter(r => r.isPrivate === false || r.owner === user.toVcsRepositoryOwner) + deleteAction <- Sync[F].delay( + linkConfig.createFullUri( + Uri(path = + Uri.Path( + Vector( + Uri.Path.Segment(s"~$repositoryOwnerName"), + Uri.Path.Segment(repositoryName.toString), + Uri.Path.Segment("delete") + ) + ) + ) + ) + ) + resp <- repo match { + case None => NotFound() + case Some(repo) => + Ok( + views.html.deleteRepository()( + deleteAction, + repo, + csrf, + Option(s"~$repositoryOwnerName/$repositoryName - delete"), + user + ) + ) + } + } yield resp + } + + private val showEditRepositoryForm: AuthedRoutes[Account, F] = AuthedRoutes.of { + case ar @ GET -> Root / UsernamePathParameter(repositoryOwnerName) / VcsRepositoryNamePathParameter( + repositoryName + ) / "edit" as user => + for { + csrf <- Sync[F].delay(ar.req.getCsrfToken) + owner <- vcsMetadataRepo.findVcsRepositoryOwner(repositoryOwnerName) + loadedRepo <- owner match { + case None => Sync[F].pure(None) + case Some(owner) => vcsMetadataRepo.findVcsRepository(owner, repositoryName) + } + // TODO Replace with whatever we implement as proper permission model. ;-) + repo = loadedRepo.filter(r => r.isPrivate === false || r.owner === user.toVcsRepositoryOwner) + editAction <- Sync[F].delay( + linkConfig.createFullUri( + Uri(path = + Uri.Path( + Vector( + Uri.Path.Segment(s"~$repositoryOwnerName"), + Uri.Path.Segment(repositoryName.toString), + Uri.Path.Segment("edit") + ) + ) + ) + ) + ) + formData <- Sync[F].delay(repo.map(EditVcsRepositoryForm.fromVcsRepository).map(_.toMap)) + resp <- formData match { + case None => NotFound() + case Some(formData) => + Ok( + views.html.editRepository()( + editAction, + csrf, + Option(s"~$repositoryOwnerName/$repositoryName - edit"), + user + )(formData) + ) + } + } yield resp + } + private val showRepositories: AuthedRoutes[Account, F] = AuthedRoutes.of { case ar @ GET -> Root / UsernamePathParameter(repositoriesOwnerName) as user => for { @@ -902,10 +1166,15 @@ } val protectedRoutes = - downloadDistribution <+> forkRepository <+> showAllRepositories <+> showRepositories <+> parseCreateRepositoryForm <+> showCreateRepositoryForm <+> showRepositoryOverview <+> showRepositoryHistory <+> showRepositoryFiles + downloadDistribution <+> forkRepository <+> showAllRepositories <+> showRepositories <+> + parseCreateRepositoryForm <+> parseDeleteRepositoryForm <+> parseEditRepositoryForm <+> + showCreateRepositoryForm <+> showDeleteRepositoryForm <+> showEditRepositoryForm <+> + showRepositoryOverview <+> showRepositoryHistory <+> showRepositoryFiles val routes = - cloneRepository <+> downloadDistributionForGuests <+> showAllRepositoriesForGuests <+> showRepositoriesForGuests <+> showRepositoryOverviewForGuests <+> showRepositoryHistoryForGuests <+> showRepositoryFilesForGuests + cloneRepository <+> downloadDistributionForGuests <+> showAllRepositoriesForGuests <+> + showRepositoriesForGuests <+> showRepositoryOverviewForGuests <+> showRepositoryHistoryForGuests <+> + showRepositoryFilesForGuests } 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 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/deleteRepository.scala.html 2025-02-02 03:51:09.989477878 +0000 @@ -0,0 +1,29 @@ +@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(deleteAction: Uri, vcsRepository: VcsRepository, csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account) +@main(baseUri, lang)()(csrf, title, user.some) { +@defining(lang.toLocale) { implicit locale => + <div class="content"> + <div class="pure-g"> + <div class="pure-u-1-1 pure-u-md-1-1"> + <div class="l-box"> + <div class="repo-delete-form"> + <h3>@Messages("form.repository.delete.title", s"~${vcsRepository.owner.name}/${vcsRepository.name}")</h3> + <form action="@deleteAction" class="pure-form" method="POST" accept-charset="UTF-8"> + <fieldset> + <p class="alert alert-error"> + <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span> + @Messages("form.repository.delete.notice") + </p> + <label for="i-am-sure" class="pure-checkbox"><input id="i-am-sure" name="i-am-sure" required="" type="checkbox" value="yes"/> @Messages("form.repository.delete.i-am-sure", s"~${vcsRepository.owner.name}/${vcsRepository.name}")</label> + @csrfToken(csrf) + <button type="submit" class="button-warning pure-button">@Messages("form.repository.delete.button.submit")</button> + </fieldset> + </form> + </div> + </div> + </div> + </div> + </div> +} +} + + 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 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/editRepository.scala.html 2025-02-02 03:51:09.989477878 +0000 @@ -0,0 +1,61 @@ +@import EditVcsRepositoryForm._ + +@(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) { +@defining(lang.toLocale) { implicit locale => + <div class="content"> + <div class="pure-g"> + <div class="pure-u-1-1 pure-u-md-1-1"> + <div class="l-box"> + <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-repo-form"> + <form action="@action" method="POST" accept-charset="UTF-8" class="pure-form pure-form-aligned"> + <fieldset id="repository-data"> + <div class="pure-control-group"> + <label for="@{fieldName}">@Messages("form.edit-repo.name")</label> + <input class="pure-input-1-2" id="@{fieldName}" name="@{fieldName}" maxlength="64" readonly="" required="" type="text" value="@{formData.get(fieldName)}"> + <small class="pure-form-message" id="@{fieldName}.help">@Messages("form.edit-repo.name.help")</small> + @renderFormErrors(fieldName, formErrors) + </div> + <div class="pure-control-group"> + <label for="@{fieldIsPrivate}">@Messages("form.edit-repo.is-private")</label> + <input id="@{fieldIsPrivate}" name="@{fieldIsPrivate}" type="checkbox" value="true" @if(formData.get(fieldIsPrivate).map(_ === "true").getOrElse(false)){ checked="" } else { }> + <span class="pure-form-message-inline" id="@{fieldIsPrivate}.help">@Messages("form.edit-repo.is-private.help")</span> + @renderFormErrors(fieldIsPrivate, formErrors) + </div> + <div class="pure-control-group"> + <label for="@{fieldDescription}">@Messages("form.edit-repo.description")</label> + <textarea class="pure-input-1-2" id="@{fieldDescription}" name="@{fieldDescription}" maxlength="254" rows="3">@{formData.get(fieldDescription)}</textarea> + <span class="pure-form-message" id="@{fieldDescription}.help">@Messages("form.edit-repo.description.help")</span> + @renderFormErrors(fieldDescription, formErrors) + </div> + <div class="pure-control-group"> + <label for="@{fieldWebsite}">@Messages("form.edit-repo.website")</label> + <input id="@{fieldWebsite}" name="@{fieldWebsite}" maxlength="128" placeholder="https://example.com" type="text" value="@{formData.get(fieldWebsite)}"> + <span class="pure-form-message" id="@{fieldWebsite}.help">@Messages("form.edit-repo.website.help")</span> + @renderFormErrors(fieldWebsite, formErrors) + </div> + @csrfToken(csrf) + <div class="pure-controls"> + <button type="submit" class="pure-button">@Messages("form.edit-repo.button.submit")</button> + </div> + </fieldset> + </form> + </div> + </div> + </div> + </div> + </div> +} +} + 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-02-02 03:51:09.989477878 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html 2025-02-02 03:51:09.989477878 +0000 @@ -14,6 +14,12 @@ @for(website <- vcsRepository.website) { <li class="pure-menu-item"><a class="pure-menu-link" href="@website" target="_blank" title="@Messages("repository.menu.website.tooltip", website)"><i class="fa-solid fa-up-right-from-square"></i> @Messages("repository.menu.website")</a></li> } + @if(user.exists(_.uid === vcsRepository.owner.uid)) { + <li class="pure-menu-item"><a class="pure-menu-link" href="@actionBaseUri.addSegment("edit")"><i class="fa-solid fa-pen"></i> @Messages("repository.menu.edit")</a></li> + } else { } + @if(user.exists(_.uid === vcsRepository.owner.uid)) { + <li class="pure-menu-item"><a class="pure-menu-link" href="@actionBaseUri.addSegment("delete")"><i class="fa-solid fa-trash-can"></i> @Messages("repository.menu.delete")</a></li> + } else { } </ul> </nav> @for(description <- vcsRepository.description) {