diff --git a/reposilite-backend/src/main/kotlin/com/reposilite/packages/.gitkeep b/reposilite-backend/src/main/kotlin/com/reposilite/packages/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/reposilite-backend/src/main/kotlin/com/reposilite/packages/PackageRepository.kt b/reposilite-backend/src/main/kotlin/com/reposilite/packages/PackageRepository.kt new file mode 100644 index 000000000..56bd16bba --- /dev/null +++ b/reposilite-backend/src/main/kotlin/com/reposilite/packages/PackageRepository.kt @@ -0,0 +1,7 @@ +package com.reposilite.packages + + +interface PackageRepository { + + +} diff --git a/reposilite-backend/src/main/kotlin/com/reposilite/packages/Repository.kt b/reposilite-backend/src/main/kotlin/com/reposilite/packages/Repository.kt deleted file mode 100644 index fa544a6b6..000000000 --- a/reposilite-backend/src/main/kotlin/com/reposilite/packages/Repository.kt +++ /dev/null @@ -1,4 +0,0 @@ -package com.reposilite.packages - -interface Repository { -} \ No newline at end of file diff --git a/reposilite-backend/src/main/kotlin/com/reposilite/packages/oci/.gitkeep b/reposilite-backend/src/main/kotlin/com/reposilite/packages/oci/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/reposilite-backend/src/main/kotlin/com/reposilite/packages/oci/OciFacade.kt b/reposilite-backend/src/main/kotlin/com/reposilite/packages/oci/OciFacade.kt new file mode 100644 index 000000000..f08b4a2ea --- /dev/null +++ b/reposilite-backend/src/main/kotlin/com/reposilite/packages/oci/OciFacade.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2023 dzikoysk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.reposilite.packages.oci + +import com.reposilite.journalist.Journalist +import com.reposilite.journalist.Logger +import com.reposilite.packages.oci.api.BlobResponse +import com.reposilite.packages.oci.api.ManifestResponse +import com.reposilite.packages.oci.api.SaveManifestRequest +import com.reposilite.packages.oci.api.UploadState +import com.reposilite.plugin.api.Facade +import com.reposilite.shared.ErrorResponse +import com.reposilite.shared.badRequestError +import com.reposilite.shared.notFound +import com.reposilite.shared.notFoundError +import com.reposilite.storage.StorageProvider +import com.reposilite.storage.api.toLocation +import panda.std.Result +import panda.std.asSuccess +import java.security.MessageDigest +import java.util.* + +class OciFacade( + private val journalist: Journalist, + private val storageProvider: StorageProvider, + private val ociRepositoryProvider: OciRepositoryProvider +) : Journalist, Facade { + + private val sessions = mutableMapOf() + private val sha256Hash = MessageDigest.getInstance("SHA-256") + + fun saveManifest(namespace: String, digest: String, saveManifestRequest: SaveManifestRequest): Result { + storageProvider.putFile("manifests/${namespace}/${digest}".toLocation(), saveManifestRequest.toString().toByteArray().inputStream()) + return saveManifestRequest.let { ManifestResponse(it.schemaVersion, it.mediaType, it.config, it.layers) }.asSuccess() + } + + fun saveTaggedManifest(namespace: String, tag: String, saveManifestRequest: SaveManifestRequest): Result { + val digest = sha256Hash.digest(saveManifestRequest.toString().toByteArray()).joinToString("") { "%02x".format(it) } + + storageProvider.putFile("manifests/${namespace}/${tag}/manifest".toLocation(), saveManifestRequest.toString().toByteArray().inputStream()) + storageProvider.putFile("manifests/${namespace}/${tag}/manifest.sha256".toLocation(), digest.toByteArray().inputStream()) + return saveManifestRequest.let { ManifestResponse(it.schemaVersion, it.mediaType, it.config, it.layers) }.asSuccess() + } + + fun retrieveBlobUploadSessionId(namespace: String): Result { + val sessionId = UUID.randomUUID().toString() + + sessions[sessionId] = UploadState( + sessionId = sessionId, + name = namespace, + uploadedData = ByteArray(0), + bytesReceived = 0, + createdAt = System.currentTimeMillis().toString() + ) + + return sessionId.asSuccess() + } + + fun uploadBlobStreamPart(sessionId: String, part: ByteArray): Result { + val session = sessions[sessionId] ?: return notFoundError("Session not found") + + session.uploadedData += part + session.bytesReceived += part.size + + return session.asSuccess() + } + + fun finalizeBlobUpload(namespace: String, digest: String, sessionId: String, lastPart: ByteArray?): Result { + val session = sessions[sessionId] ?: return notFoundError("Session not found") + + if (lastPart != null) { + session.bytesReceived += lastPart.size + } + + storageProvider.putFile("blobs/$namespace/$digest".toLocation(), session.uploadedData.inputStream()) + + sessions.remove(sessionId) + + return findBlobByDigest(namespace, digest) + } + + fun findBlobByDigest(namespace: String, digest: String): Result = + storageProvider.getFile("blobs/${namespace}/${digest}".toLocation()) + .map { + BlobResponse( + digest = digest, + length = it.available(), + content = it + ) + } + .mapErr { notFound("Could not find blob with specified digest") } + + fun findManifestChecksumByDigest(namespace: String, digest: String): Result { + val location = "manifests/${namespace}/${digest}".toLocation() + return storageProvider.getFile(location) + .map { it.readAllBytes().joinToString("") { "%02x".format(it) } } + } + + fun findManifestChecksumByTag(namespace: String, tag: String): Result { + val location = "manifests/${namespace}/${tag}/manifest.sha256".toLocation() + + return storageProvider.getFile(location) + .map { it.readAllBytes().joinToString("") { "%02x".format(it) } } + } + + fun findManifestTagByDigest(namespace: String, digest: String): Result { + val tagsDirectory = "manifests/${namespace}".toLocation() + + // todo replace with exposed (digest to tag mapping) + return storageProvider.getFiles(tagsDirectory) + .flatMap { files -> + files + .map { storageProvider.getFile(it.resolve("manifest.sha256")) } + .map { it.map { it.readAllBytes().joinToString("") { "%02x".format(it) } } } + .first() + } + } + + fun validateDigest(digest: String): Result { + if (!digest.startsWith("sha256:")) { + return badRequestError("Invalid digest format") + } + + return digest.asSuccess() + } + + fun getRepositories(): Collection = + ociRepositoryProvider.getRepositories() + + override fun getLogger(): Logger = + journalist.logger + +} diff --git a/reposilite-backend/src/main/kotlin/com/reposilite/packages/oci/OciRepository.kt b/reposilite-backend/src/main/kotlin/com/reposilite/packages/oci/OciRepository.kt new file mode 100644 index 000000000..c6a93727f --- /dev/null +++ b/reposilite-backend/src/main/kotlin/com/reposilite/packages/oci/OciRepository.kt @@ -0,0 +1,6 @@ +package com.reposilite.packages.oci + +data class OciRepository( + val name: String, + val type: String, +) diff --git a/reposilite-backend/src/main/kotlin/com/reposilite/packages/oci/OciRepositoryProvider.kt b/reposilite-backend/src/main/kotlin/com/reposilite/packages/oci/OciRepositoryProvider.kt new file mode 100644 index 000000000..38e435d96 --- /dev/null +++ b/reposilite-backend/src/main/kotlin/com/reposilite/packages/oci/OciRepositoryProvider.kt @@ -0,0 +1,9 @@ +package com.reposilite.packages.oci + +class OciRepositoryProvider { + + private val repositories = mutableMapOf() + + fun getRepositories(): Collection = repositories.values + +} \ No newline at end of file diff --git a/reposilite-backend/src/main/kotlin/com/reposilite/packages/oci/api/OciBlobApi.kt b/reposilite-backend/src/main/kotlin/com/reposilite/packages/oci/api/OciBlobApi.kt new file mode 100644 index 000000000..84600057f --- /dev/null +++ b/reposilite-backend/src/main/kotlin/com/reposilite/packages/oci/api/OciBlobApi.kt @@ -0,0 +1,9 @@ +package com.reposilite.packages.oci.api + +import java.io.InputStream + +data class BlobResponse( + val length: Int, + val content: InputStream, + val digest: String, +) diff --git a/reposilite-backend/src/main/kotlin/com/reposilite/packages/oci/api/OciManifestApi.kt b/reposilite-backend/src/main/kotlin/com/reposilite/packages/oci/api/OciManifestApi.kt new file mode 100644 index 000000000..6f7e392b9 --- /dev/null +++ b/reposilite-backend/src/main/kotlin/com/reposilite/packages/oci/api/OciManifestApi.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023 dzikoysk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.reposilite.packages.oci.api + +data class SaveManifestRequest( + val schemaVersion: Int, + val mediaType: String, + val config: ManifestConfig, + val layers: List, +) + +data class ManifestResponse( + val schemaVersion: Int, + val mediaType: String, + val config: ManifestConfig, + val layers: List, +) + +data class ManifestConfig( + val mediaType: String, + val size: Int, + val digest: String, +) + +data class ManifestLayer( + val mediaType: String, + val size: Int, + val digest: String, +) diff --git a/reposilite-backend/src/main/kotlin/com/reposilite/packages/oci/api/OciUploadApi.kt b/reposilite-backend/src/main/kotlin/com/reposilite/packages/oci/api/OciUploadApi.kt new file mode 100644 index 000000000..4277415c3 --- /dev/null +++ b/reposilite-backend/src/main/kotlin/com/reposilite/packages/oci/api/OciUploadApi.kt @@ -0,0 +1,9 @@ +package com.reposilite.packages.oci.api + +data class UploadState( + val sessionId: String, + val name: String, + var uploadedData: ByteArray, + var bytesReceived: Int, + val createdAt: String +) diff --git a/reposilite-backend/src/main/kotlin/com/reposilite/packages/oci/application/OciPlugin.kt b/reposilite-backend/src/main/kotlin/com/reposilite/packages/oci/application/OciPlugin.kt new file mode 100644 index 000000000..58e859109 --- /dev/null +++ b/reposilite-backend/src/main/kotlin/com/reposilite/packages/oci/application/OciPlugin.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2023 dzikoysk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.reposilite.packages.oci.application + +import com.reposilite.packages.oci.OciFacade +import com.reposilite.packages.oci.OciRepositoryProvider +import com.reposilite.packages.oci.infrastructure.OciEndpoints +import com.reposilite.plugin.api.Plugin +import com.reposilite.plugin.api.ReposilitePlugin +import com.reposilite.plugin.event +import com.reposilite.plugin.facade +import com.reposilite.token.infrastructure.AccessTokenApiEndpoints +import com.reposilite.web.api.RoutingSetupEvent + +@Plugin( + name = "oci", + dependencies = ["failure", "local-configuration", "shared-configuration", "statistics", "authentication", "access-token", "storage"] +) +internal class OciPlugin : ReposilitePlugin() { + + override fun initialize(): OciFacade { + val ociRepositoryProvider = OciRepositoryProvider() + + val ociFacade = OciFacade( + journalist = this, + storageProvider = facade(), + ociRepositoryProvider = ociRepositoryProvider + ) + + // register endpoints + event { event: RoutingSetupEvent -> + ociFacade.getRepositories().forEach { + when (it.type) { + "oci" -> { + val ociEndpoints = OciEndpoints(ociFacade) + + event.register(ociEndpoints.saveManifest(it.name)) + event.register(ociEndpoints.retrieveBlobUploadSessionId(it.name)) + event.register(ociEndpoints.findManifestChecksumByReference(it.name)) + event.register(ociEndpoints.findBlobByDigest(it.name)) + event.register(ociEndpoints.uploadBlobStreamPart(it.name)) + event.register(ociEndpoints.finalizeBlobUpload(it.name)) + } + } + } + } + + return ociFacade + } + +} diff --git a/reposilite-backend/src/main/kotlin/com/reposilite/packages/oci/infrastructure/OciEndpoints.kt b/reposilite-backend/src/main/kotlin/com/reposilite/packages/oci/infrastructure/OciEndpoints.kt new file mode 100644 index 000000000..a11815ec6 --- /dev/null +++ b/reposilite-backend/src/main/kotlin/com/reposilite/packages/oci/infrastructure/OciEndpoints.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2023 dzikoysk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.reposilite.packages.oci.infrastructure + +import com.reposilite.packages.oci.OciFacade +import com.reposilite.packages.oci.api.ManifestResponse +import com.reposilite.packages.oci.api.SaveManifestRequest +import com.reposilite.shared.badRequest +import com.reposilite.shared.badRequestError +import com.reposilite.web.api.ReposiliteRoute +import io.javalin.community.routing.Route.* +import io.javalin.http.HandlerType +import io.javalin.http.bodyAsClass +import panda.std.Result.supplyThrowing + +internal class OciEndpoints( + private val ociFacade: OciFacade, +) { + + fun saveManifest(repository: String) = + ReposiliteRoute("/api/oci/v2/$repository/manifests/{reference}", PUT) { + accessed { + val contentType = ctx.header("Content-Type") + if (contentType != "application/vnd.docker.distribution.manifest.v2+json") { + response = badRequestError("Invalid content type") + return@accessed + } + + val reference = parameter("reference") ?: return@accessed + + response = supplyThrowing { ctx.bodyAsClass() } + .mapErr { badRequest("Request does not contain valid body") } + .flatMap { saveManifestRequest -> + ociFacade.validateDigest(reference) + .fold( + { ociFacade.saveManifest(repository, reference, saveManifestRequest) }, + { ociFacade.saveTaggedManifest(repository, reference, saveManifestRequest) } + ) + } + } + } + + fun retrieveBlobUploadSessionId(repository: String) = + ReposiliteRoute("/api/oci/v2/$repository/blobs/uploads", POST) { + accessed { + val digest = queryParameter("digest") + if (digest == null) { + response = ociFacade.retrieveBlobUploadSessionId(repository) + .map { + ctx.status(202) + ctx.header("Location", "/api/oci/v2/$repository/blobs/uploads/$it") + } + + return@accessed + } + } + } + + fun uploadBlobStreamPart(repository: String) = + ReposiliteRoute("/api/oci/v2/$repository/blobs/uploads/{sessionId}", PATCH) { + accessed { + val contentType = ctx.header("Content-Type") + if (contentType != "application/octet-stream") { + response = badRequestError("Invalid content type") + return@accessed + } + + val sessionId = parameter("sessionId") ?: return@accessed + + response = supplyThrowing { ctx.bodyAsBytes() } + .mapErr { badRequest("Body does not contain any bytes") } + .flatMap { ociFacade.uploadBlobStreamPart(sessionId, it) } + .map { + ctx.status(202) + ctx.header("Location", "/api/oci/v2/$repository/blobs/uploads/$sessionId") + ctx.header("Range", "0-${it.bytesReceived - 1}") + } + } + } + + fun finalizeBlobUpload(repository: String) = + ReposiliteRoute("/api/oci/v2/$repository/blobs/{sessionId}", PUT) { + accessed { + val sessionId = parameter("sessionId") ?: return@accessed + val digest = queryParameter("digest") + if (digest == null) { + response = badRequestError("No digest provided") + return@accessed + } + + response = supplyThrowing { ctx.bodyAsBytes() } + .fold( + { ociFacade.finalizeBlobUpload(repository, digest, sessionId, it) }, + { ociFacade.finalizeBlobUpload(repository, digest, sessionId, null) }, + ) + .map { + ctx.status(201) + ctx.header("Location", "/api/oci/v2/$repository/blobs/${it.digest}") + } + } + } + + fun findBlobByDigest(repository: String) = + ReposiliteRoute("/api/oci/v2/$repository/blobs/{digest}", GET, HEAD) { + accessed { + val digest = parameter("digest") ?: return@accessed + + response = ociFacade.findBlobByDigest(repository, digest) + .peek { + ctx.header("Content-Length", it.length.toString()) + ctx.header("Docker-Content-Digest", it.digest) + } + .takeIf { ctx.method() == HandlerType.GET } + ?.map { it.content.readNBytes(it.length) } + } + } + + fun findManifestChecksumByReference(repository: String) = + ReposiliteRoute("/api/oci/v2/$repository/manifests/{reference}", HEAD) { + accessed { + val reference = parameter("reference") ?: return@accessed + + response = ociFacade.validateDigest(reference) + .fold( + { ociFacade.findManifestChecksumByDigest(repository, reference) }, + { ociFacade.findManifestChecksumByTag(repository, reference) } + ) + .map { + ctx.status(200) + ctx.header("Docker-Content-Digest", it) + } + } + } + +}