~jan0sch/smederee

Showing details for patch 286722a69f64a614c6d4cdd56433b54277aa2bdc.
2022-10-12 (Wed), 10:52 AM - Jens Grassel - 286722a69f64a614c6d4cdd56433b54277aa2bdc

VCS: Allow editing and deleting of repositories.

- add editing for repositories (no renaming yet)
- add deleting for repositories
Summary of changes
3 files added
  • modules/hub/src/main/scala/de/smederee/hub/EditVcsRepositoryForm.scala
  • modules/hub/src/main/twirl/de/smederee/hub/views/deleteRepository.scala.html
  • modules/hub/src/main/twirl/de/smederee/hub/views/editRepository.scala.html
6 files modified with 364 lines added and 8 lines removed
  • modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala with 42 added and 6 removed lines
  • modules/hub/src/main/resources/messages_en.properties with 16 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala with 11 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala with 18 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala with 271 added and 2 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html with 6 added and 0 removed lines
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) {