gitea source for verification 2026-05-22
This commit is contained in:
24
routers/api/actions/actions.go
Normal file
24
routers/api/actions/actions.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/routers/api/actions/ping"
|
||||
"code.gitea.io/gitea/routers/api/actions/runner"
|
||||
)
|
||||
|
||||
func Routes(prefix string) *web.Router {
|
||||
m := web.NewRouter()
|
||||
|
||||
path, handler := ping.NewPingServiceHandler()
|
||||
m.Post(path+"*", http.StripPrefix(prefix, handler).ServeHTTP)
|
||||
|
||||
path, handler = runner.NewRunnerServiceHandler()
|
||||
m.Post(path+"*", http.StripPrefix(prefix, handler).ServeHTTP)
|
||||
|
||||
return m
|
||||
}
|
||||
1058
routers/api/actions/artifact.pb.go
generated
Normal file
1058
routers/api/actions/artifact.pb.go
generated
Normal file
File diff suppressed because it is too large
Load Diff
73
routers/api/actions/artifact.proto
Normal file
73
routers/api/actions/artifact.proto
Normal file
@@ -0,0 +1,73 @@
|
||||
syntax = "proto3";
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "google/protobuf/wrappers.proto";
|
||||
|
||||
package github.actions.results.api.v1;
|
||||
|
||||
message CreateArtifactRequest {
|
||||
string workflow_run_backend_id = 1;
|
||||
string workflow_job_run_backend_id = 2;
|
||||
string name = 3;
|
||||
google.protobuf.Timestamp expires_at = 4;
|
||||
int32 version = 5;
|
||||
}
|
||||
|
||||
message CreateArtifactResponse {
|
||||
bool ok = 1;
|
||||
string signed_upload_url = 2;
|
||||
}
|
||||
|
||||
message FinalizeArtifactRequest {
|
||||
string workflow_run_backend_id = 1;
|
||||
string workflow_job_run_backend_id = 2;
|
||||
string name = 3;
|
||||
int64 size = 4;
|
||||
google.protobuf.StringValue hash = 5;
|
||||
}
|
||||
|
||||
message FinalizeArtifactResponse {
|
||||
bool ok = 1;
|
||||
int64 artifact_id = 2;
|
||||
}
|
||||
|
||||
message ListArtifactsRequest {
|
||||
string workflow_run_backend_id = 1;
|
||||
string workflow_job_run_backend_id = 2;
|
||||
google.protobuf.StringValue name_filter = 3;
|
||||
google.protobuf.Int64Value id_filter = 4;
|
||||
}
|
||||
|
||||
message ListArtifactsResponse {
|
||||
repeated ListArtifactsResponse_MonolithArtifact artifacts = 1;
|
||||
}
|
||||
|
||||
message ListArtifactsResponse_MonolithArtifact {
|
||||
string workflow_run_backend_id = 1;
|
||||
string workflow_job_run_backend_id = 2;
|
||||
int64 database_id = 3;
|
||||
string name = 4;
|
||||
int64 size = 5;
|
||||
google.protobuf.Timestamp created_at = 6;
|
||||
}
|
||||
|
||||
message GetSignedArtifactURLRequest {
|
||||
string workflow_run_backend_id = 1;
|
||||
string workflow_job_run_backend_id = 2;
|
||||
string name = 3;
|
||||
}
|
||||
|
||||
message GetSignedArtifactURLResponse {
|
||||
string signed_url = 1;
|
||||
}
|
||||
|
||||
message DeleteArtifactRequest {
|
||||
string workflow_run_backend_id = 1;
|
||||
string workflow_job_run_backend_id = 2;
|
||||
string name = 3;
|
||||
}
|
||||
|
||||
message DeleteArtifactResponse {
|
||||
bool ok = 1;
|
||||
int64 artifact_id = 2;
|
||||
}
|
||||
503
routers/api/actions/artifacts.go
Normal file
503
routers/api/actions/artifacts.go
Normal file
@@ -0,0 +1,503 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
// GitHub Actions Artifacts API Simple Description
|
||||
//
|
||||
// 1. Upload artifact
|
||||
// 1.1. Post upload url
|
||||
// Post: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts?api-version=6.0-preview
|
||||
// Request:
|
||||
// {
|
||||
// "Type": "actions_storage",
|
||||
// "Name": "artifact"
|
||||
// }
|
||||
// Response:
|
||||
// {
|
||||
// "fileContainerResourceUrl":"/api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/upload"
|
||||
// }
|
||||
// it acquires an upload url for artifact upload
|
||||
// 1.2. Upload artifact
|
||||
// PUT: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/upload?itemPath=artifact%2Ffilename
|
||||
// it upload chunk with headers:
|
||||
// x-tfs-filelength: 1024 // total file length
|
||||
// content-length: 1024 // chunk length
|
||||
// x-actions-results-md5: md5sum // md5sum of chunk
|
||||
// content-range: bytes 0-1023/1024 // chunk range
|
||||
// we save all chunks to one storage directory after md5sum check
|
||||
// 1.3. Confirm upload
|
||||
// PATCH: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/upload?itemPath=artifact%2Ffilename
|
||||
// it confirm upload and merge all chunks to one file, save this file to storage
|
||||
//
|
||||
// 2. Download artifact
|
||||
// 2.1 list artifacts
|
||||
// GET: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts?api-version=6.0-preview
|
||||
// Response:
|
||||
// {
|
||||
// "count": 1,
|
||||
// "value": [
|
||||
// {
|
||||
// "name": "artifact",
|
||||
// "fileContainerResourceUrl": "/api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/path"
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
// 2.2 download artifact
|
||||
// GET: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/path?api-version=6.0-preview
|
||||
// Response:
|
||||
// {
|
||||
// "value": [
|
||||
// {
|
||||
// "contentLocation": "/api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/download",
|
||||
// "path": "artifact/filename",
|
||||
// "itemType": "file"
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
// 2.3 download artifact file
|
||||
// GET: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/download?itemPath=artifact%2Ffilename
|
||||
// Response:
|
||||
// download file
|
||||
//
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/httplib"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/storage"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
web_types "code.gitea.io/gitea/modules/web/types"
|
||||
actions_service "code.gitea.io/gitea/services/actions"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
const artifactRouteBase = "/_apis/pipelines/workflows/{run_id}/artifacts"
|
||||
|
||||
type artifactContextKeyType struct{}
|
||||
|
||||
var artifactContextKey = artifactContextKeyType{}
|
||||
|
||||
type ArtifactContext struct {
|
||||
*context.Base
|
||||
|
||||
ActionTask *actions.ActionTask
|
||||
}
|
||||
|
||||
func init() {
|
||||
web.RegisterResponseStatusProvider[*ArtifactContext](func(req *http.Request) web_types.ResponseStatusProvider {
|
||||
return req.Context().Value(artifactContextKey).(*ArtifactContext)
|
||||
})
|
||||
}
|
||||
|
||||
func ArtifactsRoutes(prefix string) *web.Router {
|
||||
m := web.NewRouter()
|
||||
m.Use(ArtifactContexter())
|
||||
|
||||
r := artifactRoutes{
|
||||
prefix: prefix,
|
||||
fs: storage.ActionsArtifacts,
|
||||
}
|
||||
|
||||
m.Group(artifactRouteBase, func() {
|
||||
// retrieve, list and confirm artifacts
|
||||
m.Combo("").Get(r.listArtifacts).Post(r.getUploadArtifactURL).Patch(r.comfirmUploadArtifact)
|
||||
// handle container artifacts list and download
|
||||
m.Put("/{artifact_hash}/upload", r.uploadArtifact)
|
||||
// handle artifacts download
|
||||
m.Get("/{artifact_hash}/download_url", r.getDownloadArtifactURL)
|
||||
m.Get("/{artifact_id}/download", r.downloadArtifact)
|
||||
})
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func ArtifactContexter() func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
|
||||
base := context.NewBaseContext(resp, req)
|
||||
|
||||
ctx := &ArtifactContext{Base: base}
|
||||
ctx.SetContextValue(artifactContextKey, ctx)
|
||||
|
||||
// action task call server api with Bearer ACTIONS_RUNTIME_TOKEN
|
||||
// we should verify the ACTIONS_RUNTIME_TOKEN
|
||||
authHeader := req.Header.Get("Authorization")
|
||||
if len(authHeader) == 0 || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
ctx.HTTPError(http.StatusUnauthorized, "Bad authorization header")
|
||||
return
|
||||
}
|
||||
|
||||
// New act_runner uses jwt to authenticate
|
||||
tID, err := actions_service.ParseAuthorizationToken(req)
|
||||
|
||||
var task *actions.ActionTask
|
||||
if err == nil {
|
||||
task, err = actions.GetTaskByID(req.Context(), tID)
|
||||
if err != nil {
|
||||
log.Error("Error runner api getting task by ID: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error runner api getting task by ID")
|
||||
return
|
||||
}
|
||||
if task.Status != actions.StatusRunning {
|
||||
log.Error("Error runner api getting task: task is not running")
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error runner api getting task: task is not running")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Old act_runner uses GITEA_TOKEN to authenticate
|
||||
authToken := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
task, err = actions.GetRunningTaskByToken(req.Context(), authToken)
|
||||
if err != nil {
|
||||
log.Error("Error runner api getting task: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error runner api getting task")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := task.LoadJob(req.Context()); err != nil {
|
||||
log.Error("Error runner api getting job: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error runner api getting job")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.ActionTask = task
|
||||
next.ServeHTTP(ctx.Resp, ctx.Req)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type artifactRoutes struct {
|
||||
prefix string
|
||||
fs storage.ObjectStorage
|
||||
}
|
||||
|
||||
func (ar artifactRoutes) buildArtifactURL(ctx *ArtifactContext, runID int64, artifactHash, suffix string) string {
|
||||
uploadURL := strings.TrimSuffix(httplib.GuessCurrentAppURL(ctx), "/") + strings.TrimSuffix(ar.prefix, "/") +
|
||||
strings.ReplaceAll(artifactRouteBase, "{run_id}", strconv.FormatInt(runID, 10)) +
|
||||
"/" + artifactHash + "/" + suffix
|
||||
return uploadURL
|
||||
}
|
||||
|
||||
type getUploadArtifactRequest struct {
|
||||
Type string
|
||||
Name string
|
||||
RetentionDays int64
|
||||
}
|
||||
|
||||
type getUploadArtifactResponse struct {
|
||||
FileContainerResourceURL string `json:"fileContainerResourceUrl"`
|
||||
}
|
||||
|
||||
// getUploadArtifactURL generates a URL for uploading an artifact
|
||||
func (ar artifactRoutes) getUploadArtifactURL(ctx *ArtifactContext) {
|
||||
_, runID, ok := validateRunID(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req getUploadArtifactRequest
|
||||
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||
log.Error("Error decode request body: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error decode request body")
|
||||
return
|
||||
}
|
||||
|
||||
// set retention days
|
||||
retentionQuery := ""
|
||||
if req.RetentionDays > 0 {
|
||||
retentionQuery = fmt.Sprintf("?retentionDays=%d", req.RetentionDays)
|
||||
}
|
||||
|
||||
// use md5(artifact_name) to create upload url
|
||||
artifactHash := fmt.Sprintf("%x", md5.Sum([]byte(req.Name)))
|
||||
resp := getUploadArtifactResponse{
|
||||
FileContainerResourceURL: ar.buildArtifactURL(ctx, runID, artifactHash, "upload"+retentionQuery),
|
||||
}
|
||||
log.Debug("[artifact] get upload url: %s", resp.FileContainerResourceURL)
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (ar artifactRoutes) uploadArtifact(ctx *ArtifactContext) {
|
||||
task, runID, ok := validateRunID(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
artifactName, artifactPath, ok := parseArtifactItemPath(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// get upload file size
|
||||
fileRealTotalSize, contentLength := getUploadFileSize(ctx)
|
||||
|
||||
// get artifact retention days
|
||||
expiredDays := setting.Actions.ArtifactRetentionDays
|
||||
if queryRetentionDays := ctx.Req.URL.Query().Get("retentionDays"); queryRetentionDays != "" {
|
||||
var err error
|
||||
expiredDays, err = strconv.ParseInt(queryRetentionDays, 10, 64)
|
||||
if err != nil {
|
||||
log.Error("Error parse retention days: %v", err)
|
||||
ctx.HTTPError(http.StatusBadRequest, "Error parse retention days")
|
||||
return
|
||||
}
|
||||
}
|
||||
log.Debug("[artifact] upload chunk, name: %s, path: %s, size: %d, retention days: %d",
|
||||
artifactName, artifactPath, fileRealTotalSize, expiredDays)
|
||||
|
||||
// create or get artifact with name and path
|
||||
artifact, err := actions.CreateArtifact(ctx, task, artifactName, artifactPath, expiredDays)
|
||||
if err != nil {
|
||||
log.Error("Error create or get artifact: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error create or get artifact")
|
||||
return
|
||||
}
|
||||
|
||||
// save chunk to storage, if success, return chunk stotal size
|
||||
// if artifact is not gzip when uploading, chunksTotalSize == fileRealTotalSize
|
||||
// if artifact is gzip when uploading, chunksTotalSize < fileRealTotalSize
|
||||
chunksTotalSize, err := saveUploadChunk(ar.fs, ctx, artifact, contentLength, runID)
|
||||
if err != nil {
|
||||
log.Error("Error save upload chunk: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error save upload chunk")
|
||||
return
|
||||
}
|
||||
|
||||
// update artifact size if zero or not match, over write artifact size
|
||||
if artifact.FileSize == 0 ||
|
||||
artifact.FileCompressedSize == 0 ||
|
||||
artifact.FileSize != fileRealTotalSize ||
|
||||
artifact.FileCompressedSize != chunksTotalSize {
|
||||
artifact.FileSize = fileRealTotalSize
|
||||
artifact.FileCompressedSize = chunksTotalSize
|
||||
artifact.ContentEncoding = ctx.Req.Header.Get("Content-Encoding")
|
||||
if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil {
|
||||
log.Error("Error update artifact: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error update artifact")
|
||||
return
|
||||
}
|
||||
log.Debug("[artifact] update artifact size, artifact_id: %d, size: %d, compressed size: %d",
|
||||
artifact.ID, artifact.FileSize, artifact.FileCompressedSize)
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, map[string]string{
|
||||
"message": "success",
|
||||
})
|
||||
}
|
||||
|
||||
// comfirmUploadArtifact confirm upload artifact.
|
||||
// if all chunks are uploaded, merge them to one file.
|
||||
func (ar artifactRoutes) comfirmUploadArtifact(ctx *ArtifactContext) {
|
||||
_, runID, ok := validateRunID(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
artifactName := ctx.Req.URL.Query().Get("artifactName")
|
||||
if artifactName == "" {
|
||||
log.Error("Error artifact name is empty")
|
||||
ctx.HTTPError(http.StatusBadRequest, "Error artifact name is empty")
|
||||
return
|
||||
}
|
||||
if err := mergeChunksForRun(ctx, ar.fs, runID, artifactName); err != nil {
|
||||
log.Error("Error merge chunks: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks")
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, map[string]string{
|
||||
"message": "success",
|
||||
})
|
||||
}
|
||||
|
||||
type (
|
||||
listArtifactsResponse struct {
|
||||
Count int64 `json:"count"`
|
||||
Value []listArtifactsResponseItem `json:"value"`
|
||||
}
|
||||
listArtifactsResponseItem struct {
|
||||
Name string `json:"name"`
|
||||
FileContainerResourceURL string `json:"fileContainerResourceUrl"`
|
||||
}
|
||||
)
|
||||
|
||||
func (ar artifactRoutes) listArtifacts(ctx *ArtifactContext) {
|
||||
_, runID, ok := validateRunID(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{
|
||||
RunID: runID,
|
||||
Status: int(actions.ArtifactStatusUploadConfirmed),
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("Error getting artifacts: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if len(artifacts) == 0 {
|
||||
log.Debug("[artifact] handleListArtifacts, no artifacts")
|
||||
ctx.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
items []listArtifactsResponseItem
|
||||
values = make(map[string]bool)
|
||||
)
|
||||
|
||||
for _, art := range artifacts {
|
||||
if values[art.ArtifactName] {
|
||||
continue
|
||||
}
|
||||
artifactHash := fmt.Sprintf("%x", md5.Sum([]byte(art.ArtifactName)))
|
||||
item := listArtifactsResponseItem{
|
||||
Name: art.ArtifactName,
|
||||
FileContainerResourceURL: ar.buildArtifactURL(ctx, runID, artifactHash, "download_url"),
|
||||
}
|
||||
items = append(items, item)
|
||||
values[art.ArtifactName] = true
|
||||
|
||||
log.Debug("[artifact] handleListArtifacts, name: %s, url: %s", item.Name, item.FileContainerResourceURL)
|
||||
}
|
||||
|
||||
respData := listArtifactsResponse{
|
||||
Count: int64(len(items)),
|
||||
Value: items,
|
||||
}
|
||||
ctx.JSON(http.StatusOK, respData)
|
||||
}
|
||||
|
||||
type (
|
||||
downloadArtifactResponse struct {
|
||||
Value []downloadArtifactResponseItem `json:"value"`
|
||||
}
|
||||
downloadArtifactResponseItem struct {
|
||||
Path string `json:"path"`
|
||||
ItemType string `json:"itemType"`
|
||||
ContentLocation string `json:"contentLocation"`
|
||||
}
|
||||
)
|
||||
|
||||
// getDownloadArtifactURL generates download url for each artifact
|
||||
func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) {
|
||||
_, runID, ok := validateRunID(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
itemPath := util.PathJoinRel(ctx.Req.URL.Query().Get("itemPath"))
|
||||
if !validateArtifactHash(ctx, itemPath) {
|
||||
return
|
||||
}
|
||||
|
||||
artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{
|
||||
RunID: runID,
|
||||
ArtifactName: itemPath,
|
||||
Status: int(actions.ArtifactStatusUploadConfirmed),
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("Error getting artifacts: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if len(artifacts) == 0 {
|
||||
log.Debug("[artifact] getDownloadArtifactURL, no artifacts")
|
||||
ctx.HTTPError(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if itemPath != artifacts[0].ArtifactName {
|
||||
log.Error("Error dismatch artifact name, itemPath: %v, artifact: %v", itemPath, artifacts[0].ArtifactName)
|
||||
ctx.HTTPError(http.StatusBadRequest, "Error dismatch artifact name")
|
||||
return
|
||||
}
|
||||
|
||||
var items []downloadArtifactResponseItem
|
||||
for _, artifact := range artifacts {
|
||||
var downloadURL string
|
||||
if setting.Actions.ArtifactStorage.ServeDirect() {
|
||||
u, err := ar.fs.URL(artifact.StoragePath, artifact.ArtifactName, ctx.Req.Method, nil)
|
||||
if err != nil && !errors.Is(err, storage.ErrURLNotSupported) {
|
||||
log.Error("Error getting serve direct url: %v", err)
|
||||
}
|
||||
if u != nil {
|
||||
downloadURL = u.String()
|
||||
}
|
||||
}
|
||||
if downloadURL == "" {
|
||||
downloadURL = ar.buildArtifactURL(ctx, runID, strconv.FormatInt(artifact.ID, 10), "download")
|
||||
}
|
||||
item := downloadArtifactResponseItem{
|
||||
Path: util.PathJoinRel(itemPath, artifact.ArtifactPath),
|
||||
ItemType: "file",
|
||||
ContentLocation: downloadURL,
|
||||
}
|
||||
log.Debug("[artifact] getDownloadArtifactURL, path: %s, url: %s", item.Path, item.ContentLocation)
|
||||
items = append(items, item)
|
||||
}
|
||||
respData := downloadArtifactResponse{
|
||||
Value: items,
|
||||
}
|
||||
ctx.JSON(http.StatusOK, respData)
|
||||
}
|
||||
|
||||
// downloadArtifact downloads artifact content
|
||||
func (ar artifactRoutes) downloadArtifact(ctx *ArtifactContext) {
|
||||
_, runID, ok := validateRunID(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
artifactID := ctx.PathParamInt64("artifact_id")
|
||||
artifact, exist, err := db.GetByID[actions.ActionArtifact](ctx, artifactID)
|
||||
if err != nil {
|
||||
log.Error("Error getting artifact: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !exist {
|
||||
log.Error("artifact with ID %d does not exist", artifactID)
|
||||
ctx.HTTPError(http.StatusNotFound, fmt.Sprintf("artifact with ID %d does not exist", artifactID))
|
||||
return
|
||||
}
|
||||
if artifact.RunID != runID {
|
||||
log.Error("Error mismatch runID and artifactID, task: %v, artifact: %v", runID, artifactID)
|
||||
ctx.HTTPError(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if artifact.Status != actions.ArtifactStatusUploadConfirmed {
|
||||
log.Error("Error artifact not found: %s", artifact.Status.ToString())
|
||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||
return
|
||||
}
|
||||
|
||||
fd, err := ar.fs.Open(artifact.StoragePath)
|
||||
if err != nil {
|
||||
log.Error("Error opening file: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
defer fd.Close()
|
||||
|
||||
// if artifact is compressed, set content-encoding header to gzip
|
||||
if artifact.ContentEncoding == "gzip" {
|
||||
ctx.Resp.Header().Set("Content-Encoding", "gzip")
|
||||
}
|
||||
log.Debug("[artifact] downloadArtifact, name: %s, path: %s, storage: %s, size: %d", artifact.ArtifactName, artifact.ArtifactPath, artifact.StoragePath, artifact.FileSize)
|
||||
ctx.ServeContent(fd, &context.ServeHeaderOptions{
|
||||
Filename: artifact.ArtifactName,
|
||||
LastModified: artifact.CreatedUnix.AsLocalTime(),
|
||||
})
|
||||
}
|
||||
301
routers/api/actions/artifacts_chunks.go
Normal file
301
routers/api/actions/artifacts_chunks.go
Normal file
@@ -0,0 +1,301 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/storage"
|
||||
)
|
||||
|
||||
func saveUploadChunkBase(st storage.ObjectStorage, ctx *ArtifactContext,
|
||||
artifact *actions.ActionArtifact,
|
||||
contentSize, runID, start, end, length int64, checkMd5 bool,
|
||||
) (int64, error) {
|
||||
// build chunk store path
|
||||
storagePath := fmt.Sprintf("tmp%d/%d-%d-%d-%d.chunk", runID, runID, artifact.ID, start, end)
|
||||
var r io.Reader = ctx.Req.Body
|
||||
var hasher hash.Hash
|
||||
if checkMd5 {
|
||||
// use io.TeeReader to avoid reading all body to md5 sum.
|
||||
// it writes data to hasher after reading end
|
||||
// if hash is not matched, delete the read-end result
|
||||
hasher = md5.New()
|
||||
r = io.TeeReader(r, hasher)
|
||||
}
|
||||
// save chunk to storage
|
||||
writtenSize, err := st.Save(storagePath, r, contentSize)
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("save chunk to storage error: %v", err)
|
||||
}
|
||||
var checkErr error
|
||||
if checkMd5 {
|
||||
// check md5
|
||||
reqMd5String := ctx.Req.Header.Get(artifactXActionsResultsMD5Header)
|
||||
chunkMd5String := base64.StdEncoding.EncodeToString(hasher.Sum(nil))
|
||||
log.Info("[artifact] check chunk md5, sum: %s, header: %s", chunkMd5String, reqMd5String)
|
||||
// if md5 not match, delete the chunk
|
||||
if reqMd5String != chunkMd5String {
|
||||
checkErr = errors.New("md5 not match")
|
||||
}
|
||||
}
|
||||
if writtenSize != contentSize {
|
||||
checkErr = errors.Join(checkErr, fmt.Errorf("writtenSize %d not match contentSize %d", writtenSize, contentSize))
|
||||
}
|
||||
if checkErr != nil {
|
||||
if err := st.Delete(storagePath); err != nil {
|
||||
log.Error("Error deleting chunk: %s, %v", storagePath, err)
|
||||
}
|
||||
return -1, checkErr
|
||||
}
|
||||
log.Info("[artifact] save chunk %s, size: %d, artifact id: %d, start: %d, end: %d",
|
||||
storagePath, contentSize, artifact.ID, start, end)
|
||||
// return chunk total size
|
||||
return length, nil
|
||||
}
|
||||
|
||||
func saveUploadChunk(st storage.ObjectStorage, ctx *ArtifactContext,
|
||||
artifact *actions.ActionArtifact,
|
||||
contentSize, runID int64,
|
||||
) (int64, error) {
|
||||
// parse content-range header, format: bytes 0-1023/146515
|
||||
contentRange := ctx.Req.Header.Get("Content-Range")
|
||||
start, end, length := int64(0), int64(0), int64(0)
|
||||
if _, err := fmt.Sscanf(contentRange, "bytes %d-%d/%d", &start, &end, &length); err != nil {
|
||||
log.Warn("parse content range error: %v, content-range: %s", err, contentRange)
|
||||
return -1, fmt.Errorf("parse content range error: %v", err)
|
||||
}
|
||||
return saveUploadChunkBase(st, ctx, artifact, contentSize, runID, start, end, length, true)
|
||||
}
|
||||
|
||||
func appendUploadChunk(st storage.ObjectStorage, ctx *ArtifactContext,
|
||||
artifact *actions.ActionArtifact,
|
||||
start, contentSize, runID int64,
|
||||
) (int64, error) {
|
||||
end := start + contentSize - 1
|
||||
return saveUploadChunkBase(st, ctx, artifact, contentSize, runID, start, end, contentSize, false)
|
||||
}
|
||||
|
||||
type chunkFileItem struct {
|
||||
RunID int64
|
||||
ArtifactID int64
|
||||
Start int64
|
||||
End int64
|
||||
Path string
|
||||
}
|
||||
|
||||
func listChunksByRunID(st storage.ObjectStorage, runID int64) (map[int64][]*chunkFileItem, error) {
|
||||
storageDir := fmt.Sprintf("tmp%d", runID)
|
||||
var chunks []*chunkFileItem
|
||||
if err := st.IterateObjects(storageDir, func(fpath string, obj storage.Object) error {
|
||||
baseName := filepath.Base(fpath)
|
||||
// when read chunks from storage, it only contains storage dir and basename,
|
||||
// no matter the subdirectory setting in storage config
|
||||
item := chunkFileItem{Path: storageDir + "/" + baseName}
|
||||
if _, err := fmt.Sscanf(baseName, "%d-%d-%d-%d.chunk", &item.RunID, &item.ArtifactID, &item.Start, &item.End); err != nil {
|
||||
return fmt.Errorf("parse content range error: %v", err)
|
||||
}
|
||||
chunks = append(chunks, &item)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// chunks group by artifact id
|
||||
chunksMap := make(map[int64][]*chunkFileItem)
|
||||
for _, c := range chunks {
|
||||
chunksMap[c.ArtifactID] = append(chunksMap[c.ArtifactID], c)
|
||||
}
|
||||
return chunksMap, nil
|
||||
}
|
||||
|
||||
func listChunksByRunIDV4(st storage.ObjectStorage, runID, artifactID int64, blist *BlockList) ([]*chunkFileItem, error) {
|
||||
storageDir := fmt.Sprintf("tmpv4%d", runID)
|
||||
var chunks []*chunkFileItem
|
||||
chunkMap := map[string]*chunkFileItem{}
|
||||
dummy := &chunkFileItem{}
|
||||
for _, name := range blist.Latest {
|
||||
chunkMap[name] = dummy
|
||||
}
|
||||
if err := st.IterateObjects(storageDir, func(fpath string, obj storage.Object) error {
|
||||
baseName := filepath.Base(fpath)
|
||||
if !strings.HasPrefix(baseName, "block-") {
|
||||
return nil
|
||||
}
|
||||
// when read chunks from storage, it only contains storage dir and basename,
|
||||
// no matter the subdirectory setting in storage config
|
||||
item := chunkFileItem{Path: storageDir + "/" + baseName, ArtifactID: artifactID}
|
||||
var size int64
|
||||
var b64chunkName string
|
||||
if _, err := fmt.Sscanf(baseName, "block-%d-%d-%s", &item.RunID, &size, &b64chunkName); err != nil {
|
||||
return fmt.Errorf("parse content range error: %v", err)
|
||||
}
|
||||
rchunkName, err := base64.URLEncoding.DecodeString(b64chunkName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse chunkName: %v", err)
|
||||
}
|
||||
chunkName := string(rchunkName)
|
||||
item.End = item.Start + size - 1
|
||||
if _, ok := chunkMap[chunkName]; ok {
|
||||
chunkMap[chunkName] = &item
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i, name := range blist.Latest {
|
||||
chunk, ok := chunkMap[name]
|
||||
if !ok || chunk.Path == "" {
|
||||
return nil, fmt.Errorf("missing Chunk (%d/%d): %s", i, len(blist.Latest), name)
|
||||
}
|
||||
chunks = append(chunks, chunk)
|
||||
if i > 0 {
|
||||
chunk.Start = chunkMap[blist.Latest[i-1]].End + 1
|
||||
chunk.End += chunk.Start
|
||||
}
|
||||
}
|
||||
return chunks, nil
|
||||
}
|
||||
|
||||
func mergeChunksForRun(ctx *ArtifactContext, st storage.ObjectStorage, runID int64, artifactName string) error {
|
||||
// read all db artifacts by name
|
||||
artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{
|
||||
RunID: runID,
|
||||
ArtifactName: artifactName,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// read all uploading chunks from storage
|
||||
chunksMap, err := listChunksByRunID(st, runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// range db artifacts to merge chunks
|
||||
for _, art := range artifacts {
|
||||
chunks, ok := chunksMap[art.ID]
|
||||
if !ok {
|
||||
log.Debug("artifact %d chunks not found", art.ID)
|
||||
continue
|
||||
}
|
||||
if err := mergeChunksForArtifact(ctx, chunks, st, art, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeChunksForArtifact(ctx *ArtifactContext, chunks []*chunkFileItem, st storage.ObjectStorage, artifact *actions.ActionArtifact, checksum string) error {
|
||||
sort.Slice(chunks, func(i, j int) bool {
|
||||
return chunks[i].Start < chunks[j].Start
|
||||
})
|
||||
allChunks := make([]*chunkFileItem, 0)
|
||||
startAt := int64(-1)
|
||||
// check if all chunks are uploaded and in order and clean repeated chunks
|
||||
for _, c := range chunks {
|
||||
// startAt is -1 means this is the first chunk
|
||||
// previous c.ChunkEnd + 1 == c.ChunkStart means this chunk is in order
|
||||
// StartAt is not -1 and c.ChunkStart is not startAt + 1 means there is a chunk missing
|
||||
if c.Start == (startAt + 1) {
|
||||
allChunks = append(allChunks, c)
|
||||
startAt = c.End
|
||||
}
|
||||
}
|
||||
// if the last chunk.End + 1 is not equal to chunk.ChunkLength, means chunks are not uploaded completely
|
||||
if startAt+1 != artifact.FileCompressedSize {
|
||||
log.Debug("[artifact] chunks are not uploaded completely, artifact_id: %d", artifact.ID)
|
||||
return nil
|
||||
}
|
||||
// use multiReader
|
||||
readers := make([]io.Reader, 0, len(allChunks))
|
||||
closeReaders := func() {
|
||||
for _, r := range readers {
|
||||
_ = r.(io.Closer).Close() // it guarantees to be io.Closer by the following loop's Open function
|
||||
}
|
||||
readers = nil
|
||||
}
|
||||
defer closeReaders()
|
||||
for _, c := range allChunks {
|
||||
var readCloser io.ReadCloser
|
||||
var err error
|
||||
if readCloser, err = st.Open(c.Path); err != nil {
|
||||
return fmt.Errorf("open chunk error: %v, %s", err, c.Path)
|
||||
}
|
||||
readers = append(readers, readCloser)
|
||||
}
|
||||
mergedReader := io.MultiReader(readers...)
|
||||
shaPrefix := "sha256:"
|
||||
var hash hash.Hash
|
||||
if strings.HasPrefix(checksum, shaPrefix) {
|
||||
hash = sha256.New()
|
||||
}
|
||||
if hash != nil {
|
||||
mergedReader = io.TeeReader(mergedReader, hash)
|
||||
}
|
||||
|
||||
// if chunk is gzip, use gz as extension
|
||||
// download-artifact action will use content-encoding header to decide if it should decompress the file
|
||||
extension := "chunk"
|
||||
if artifact.ContentEncoding == "gzip" {
|
||||
extension = "chunk.gz"
|
||||
}
|
||||
|
||||
// save merged file
|
||||
storagePath := fmt.Sprintf("%d/%d/%d.%s", artifact.RunID%255, artifact.ID%255, time.Now().UnixNano(), extension)
|
||||
written, err := st.Save(storagePath, mergedReader, artifact.FileCompressedSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save merged file error: %v", err)
|
||||
}
|
||||
if written != artifact.FileCompressedSize {
|
||||
return errors.New("merged file size is not equal to chunk length")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
closeReaders() // close before delete
|
||||
// drop chunks
|
||||
for _, c := range chunks {
|
||||
if err := st.Delete(c.Path); err != nil {
|
||||
log.Warn("Error deleting chunk: %s, %v", c.Path, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if hash != nil {
|
||||
rawChecksum := hash.Sum(nil)
|
||||
actualChecksum := hex.EncodeToString(rawChecksum)
|
||||
if !strings.HasSuffix(checksum, actualChecksum) {
|
||||
return fmt.Errorf("update artifact error checksum is invalid %v vs %v", checksum, actualChecksum)
|
||||
}
|
||||
}
|
||||
|
||||
// save storage path to artifact
|
||||
log.Debug("[artifact] merge chunks to artifact: %d, %s, old:%s", artifact.ID, storagePath, artifact.StoragePath)
|
||||
// if artifact is already uploaded, delete the old file
|
||||
if artifact.StoragePath != "" {
|
||||
if err := st.Delete(artifact.StoragePath); err != nil {
|
||||
log.Warn("Error deleting old artifact: %s, %v", artifact.StoragePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
artifact.StoragePath = storagePath
|
||||
artifact.Status = actions.ArtifactStatusUploadConfirmed
|
||||
if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil {
|
||||
return fmt.Errorf("update artifact error: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
94
routers/api/actions/artifacts_utils.go
Normal file
94
routers/api/actions/artifacts_utils.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
)
|
||||
|
||||
const (
|
||||
artifactXTfsFileLengthHeader = "x-tfs-filelength"
|
||||
artifactXActionsResultsMD5Header = "x-actions-results-md5"
|
||||
)
|
||||
|
||||
// The rules are from https://github.com/actions/toolkit/blob/main/packages/artifact/src/internal/path-and-artifact-name-validation.ts#L32
|
||||
var invalidArtifactNameChars = strings.Join([]string{"\\", "/", "\"", ":", "<", ">", "|", "*", "?", "\r", "\n"}, "")
|
||||
|
||||
func validateArtifactName(ctx *ArtifactContext, artifactName string) bool {
|
||||
if strings.ContainsAny(artifactName, invalidArtifactNameChars) {
|
||||
log.Error("Error checking artifact name contains invalid character")
|
||||
ctx.HTTPError(http.StatusBadRequest, "Error checking artifact name contains invalid character")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validateRunID(ctx *ArtifactContext) (*actions.ActionTask, int64, bool) {
|
||||
task := ctx.ActionTask
|
||||
runID := ctx.PathParamInt64("run_id")
|
||||
if task.Job.RunID != runID {
|
||||
log.Error("Error runID not match")
|
||||
ctx.HTTPError(http.StatusBadRequest, "run-id does not match")
|
||||
return nil, 0, false
|
||||
}
|
||||
return task, runID, true
|
||||
}
|
||||
|
||||
func validateRunIDV4(ctx *ArtifactContext, rawRunID string) (*actions.ActionTask, int64, bool) { //nolint:unparam // ActionTask is never used
|
||||
task := ctx.ActionTask
|
||||
runID, err := strconv.ParseInt(rawRunID, 10, 64)
|
||||
if err != nil || task.Job.RunID != runID {
|
||||
log.Error("Error runID not match")
|
||||
ctx.HTTPError(http.StatusBadRequest, "run-id does not match")
|
||||
return nil, 0, false
|
||||
}
|
||||
return task, runID, true
|
||||
}
|
||||
|
||||
func validateArtifactHash(ctx *ArtifactContext, artifactName string) bool {
|
||||
paramHash := ctx.PathParam("artifact_hash")
|
||||
// use artifact name to create upload url
|
||||
artifactHash := fmt.Sprintf("%x", md5.Sum([]byte(artifactName)))
|
||||
if paramHash == artifactHash {
|
||||
return true
|
||||
}
|
||||
log.Error("Invalid artifact hash: %s", paramHash)
|
||||
ctx.HTTPError(http.StatusBadRequest, "Invalid artifact hash")
|
||||
return false
|
||||
}
|
||||
|
||||
func parseArtifactItemPath(ctx *ArtifactContext) (string, string, bool) {
|
||||
// itemPath is generated from upload-artifact action
|
||||
// it's formatted as {artifact_name}/{artfict_path_in_runner}
|
||||
// act_runner in host mode on Windows, itemPath is joined by Windows slash '\'
|
||||
itemPath := util.PathJoinRelX(ctx.Req.URL.Query().Get("itemPath"))
|
||||
artifactName := strings.Split(itemPath, "/")[0]
|
||||
artifactPath := strings.TrimPrefix(itemPath, artifactName+"/")
|
||||
if !validateArtifactHash(ctx, artifactName) {
|
||||
return "", "", false
|
||||
}
|
||||
if !validateArtifactName(ctx, artifactName) {
|
||||
return "", "", false
|
||||
}
|
||||
return artifactName, artifactPath, true
|
||||
}
|
||||
|
||||
// getUploadFileSize returns the size of the file to be uploaded.
|
||||
// The raw size is the size of the file as reported by the header X-TFS-FileLength.
|
||||
func getUploadFileSize(ctx *ArtifactContext) (int64, int64) {
|
||||
contentLength := ctx.Req.ContentLength
|
||||
xTfsLength, _ := strconv.ParseInt(ctx.Req.Header.Get(artifactXTfsFileLengthHeader), 10, 64)
|
||||
if xTfsLength > 0 {
|
||||
return xTfsLength, contentLength
|
||||
}
|
||||
return contentLength, contentLength
|
||||
}
|
||||
586
routers/api/actions/artifactsv4.go
Normal file
586
routers/api/actions/artifactsv4.go
Normal file
@@ -0,0 +1,586 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
// GitHub Actions Artifacts V4 API Simple Description
|
||||
//
|
||||
// 1. Upload artifact
|
||||
// 1.1. CreateArtifact
|
||||
// Post: /twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact
|
||||
// Request:
|
||||
// {
|
||||
// "workflow_run_backend_id": "21",
|
||||
// "workflow_job_run_backend_id": "49",
|
||||
// "name": "test",
|
||||
// "version": 4
|
||||
// }
|
||||
// Response:
|
||||
// {
|
||||
// "ok": true,
|
||||
// "signedUploadUrl": "http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=mO7y35r4GyjN7fwg0DTv3-Fv1NDXD84KLEgLpoPOtDI=&expires=2024-01-23+21%3A48%3A37.20833956+%2B0100+CET&artifactName=test&taskID=75"
|
||||
// }
|
||||
// 1.2. Upload Zip Content to Blobstorage (unauthenticated request)
|
||||
// PUT: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=mO7y35r4GyjN7fwg0DTv3-Fv1NDXD84KLEgLpoPOtDI=&expires=2024-01-23+21%3A48%3A37.20833956+%2B0100+CET&artifactName=test&taskID=75&comp=block
|
||||
// 1.3. Continue Upload Zip Content to Blobstorage (unauthenticated request), repeat until everything is uploaded
|
||||
// PUT: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=mO7y35r4GyjN7fwg0DTv3-Fv1NDXD84KLEgLpoPOtDI=&expires=2024-01-23+21%3A48%3A37.20833956+%2B0100+CET&artifactName=test&taskID=75&comp=appendBlock
|
||||
// 1.4. BlockList xml payload to Blobstorage (unauthenticated request)
|
||||
// Files of about 800MB are parallel in parallel and / or out of order, this file is needed to ensure the correct order
|
||||
// PUT: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=mO7y35r4GyjN7fwg0DTv3-Fv1NDXD84KLEgLpoPOtDI=&expires=2024-01-23+21%3A48%3A37.20833956+%2B0100+CET&artifactName=test&taskID=75&comp=blockList
|
||||
// Request
|
||||
// <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
// <BlockList>
|
||||
// <Latest>blockId1</Latest>
|
||||
// <Latest>blockId2</Latest>
|
||||
// </BlockList>
|
||||
// 1.5. FinalizeArtifact
|
||||
// Post: /twirp/github.actions.results.api.v1.ArtifactService/FinalizeArtifact
|
||||
// Request
|
||||
// {
|
||||
// "workflow_run_backend_id": "21",
|
||||
// "workflow_job_run_backend_id": "49",
|
||||
// "name": "test",
|
||||
// "size": "2097",
|
||||
// "hash": "sha256:b6325614d5649338b87215d9536b3c0477729b8638994c74cdefacb020a2cad4"
|
||||
// }
|
||||
// Response
|
||||
// {
|
||||
// "ok": true,
|
||||
// "artifactId": "4"
|
||||
// }
|
||||
// 2. Download artifact
|
||||
// 2.1. ListArtifacts and optionally filter by artifact exact name or id
|
||||
// Post: /twirp/github.actions.results.api.v1.ArtifactService/ListArtifacts
|
||||
// Request
|
||||
// {
|
||||
// "workflow_run_backend_id": "21",
|
||||
// "workflow_job_run_backend_id": "49",
|
||||
// "name_filter": "test"
|
||||
// }
|
||||
// Response
|
||||
// {
|
||||
// "artifacts": [
|
||||
// {
|
||||
// "workflowRunBackendId": "21",
|
||||
// "workflowJobRunBackendId": "49",
|
||||
// "databaseId": "4",
|
||||
// "name": "test",
|
||||
// "size": "2093",
|
||||
// "createdAt": "2024-01-23T00:13:28Z"
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
// 2.2. GetSignedArtifactURL get the URL to download the artifact zip file of a specific artifact
|
||||
// Post: /twirp/github.actions.results.api.v1.ArtifactService/GetSignedArtifactURL
|
||||
// Request
|
||||
// {
|
||||
// "workflow_run_backend_id": "21",
|
||||
// "workflow_job_run_backend_id": "49",
|
||||
// "name": "test"
|
||||
// }
|
||||
// Response
|
||||
// {
|
||||
// "signedUrl": "http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/DownloadArtifact?sig=wHzFOwpF-6220-5CA0CIRmAX9VbiTC2Mji89UOqo1E8=&expires=2024-01-23+21%3A51%3A56.872846295+%2B0100+CET&artifactName=test&taskID=76"
|
||||
// }
|
||||
// 2.3. Download Zip from Blobstorage (unauthenticated request)
|
||||
// GET: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/DownloadArtifact?sig=wHzFOwpF-6220-5CA0CIRmAX9VbiTC2Mji89UOqo1E8=&expires=2024-01-23+21%3A51%3A56.872846295+%2B0100+CET&artifactName=test&taskID=76
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/httplib"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/storage"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
const (
|
||||
ArtifactV4RouteBase = "/twirp/github.actions.results.api.v1.ArtifactService"
|
||||
ArtifactV4ContentEncoding = "application/zip"
|
||||
)
|
||||
|
||||
type artifactV4Routes struct {
|
||||
prefix string
|
||||
fs storage.ObjectStorage
|
||||
}
|
||||
|
||||
func ArtifactV4Contexter() func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
|
||||
base := context.NewBaseContext(resp, req)
|
||||
ctx := &ArtifactContext{Base: base}
|
||||
ctx.SetContextValue(artifactContextKey, ctx)
|
||||
next.ServeHTTP(ctx.Resp, ctx.Req)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func ArtifactsV4Routes(prefix string) *web.Router {
|
||||
m := web.NewRouter()
|
||||
|
||||
r := artifactV4Routes{
|
||||
prefix: prefix,
|
||||
fs: storage.ActionsArtifacts,
|
||||
}
|
||||
|
||||
m.Group("", func() {
|
||||
m.Post("CreateArtifact", r.createArtifact)
|
||||
m.Post("FinalizeArtifact", r.finalizeArtifact)
|
||||
m.Post("ListArtifacts", r.listArtifacts)
|
||||
m.Post("GetSignedArtifactURL", r.getSignedArtifactURL)
|
||||
m.Post("DeleteArtifact", r.deleteArtifact)
|
||||
}, ArtifactContexter())
|
||||
m.Group("", func() {
|
||||
m.Put("UploadArtifact", r.uploadArtifact)
|
||||
m.Get("DownloadArtifact", r.downloadArtifact)
|
||||
}, ArtifactV4Contexter())
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func (r artifactV4Routes) buildSignature(endp, expires, artifactName string, taskID, artifactID int64) []byte {
|
||||
mac := hmac.New(sha256.New, setting.GetGeneralTokenSigningSecret())
|
||||
mac.Write([]byte(endp))
|
||||
mac.Write([]byte(expires))
|
||||
mac.Write([]byte(artifactName))
|
||||
fmt.Fprint(mac, taskID)
|
||||
fmt.Fprint(mac, artifactID)
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
func (r artifactV4Routes) buildArtifactURL(ctx *ArtifactContext, endp, artifactName string, taskID, artifactID int64) string {
|
||||
expires := time.Now().Add(60 * time.Minute).Format("2006-01-02 15:04:05.999999999 -0700 MST")
|
||||
uploadURL := strings.TrimSuffix(httplib.GuessCurrentAppURL(ctx), "/") + strings.TrimSuffix(r.prefix, "/") +
|
||||
"/" + endp + "?sig=" + base64.URLEncoding.EncodeToString(r.buildSignature(endp, expires, artifactName, taskID, artifactID)) + "&expires=" + url.QueryEscape(expires) + "&artifactName=" + url.QueryEscape(artifactName) + "&taskID=" + strconv.FormatInt(taskID, 10) + "&artifactID=" + strconv.FormatInt(artifactID, 10)
|
||||
return uploadURL
|
||||
}
|
||||
|
||||
func (r artifactV4Routes) verifySignature(ctx *ArtifactContext, endp string) (*actions.ActionTask, string, bool) {
|
||||
rawTaskID := ctx.Req.URL.Query().Get("taskID")
|
||||
rawArtifactID := ctx.Req.URL.Query().Get("artifactID")
|
||||
sig := ctx.Req.URL.Query().Get("sig")
|
||||
expires := ctx.Req.URL.Query().Get("expires")
|
||||
artifactName := ctx.Req.URL.Query().Get("artifactName")
|
||||
dsig, _ := base64.URLEncoding.DecodeString(sig)
|
||||
taskID, _ := strconv.ParseInt(rawTaskID, 10, 64)
|
||||
artifactID, _ := strconv.ParseInt(rawArtifactID, 10, 64)
|
||||
|
||||
expecedsig := r.buildSignature(endp, expires, artifactName, taskID, artifactID)
|
||||
if !hmac.Equal(dsig, expecedsig) {
|
||||
log.Error("Error unauthorized")
|
||||
ctx.HTTPError(http.StatusUnauthorized, "Error unauthorized")
|
||||
return nil, "", false
|
||||
}
|
||||
t, err := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", expires)
|
||||
if err != nil || t.Before(time.Now()) {
|
||||
log.Error("Error link expired")
|
||||
ctx.HTTPError(http.StatusUnauthorized, "Error link expired")
|
||||
return nil, "", false
|
||||
}
|
||||
task, err := actions.GetTaskByID(ctx, taskID)
|
||||
if err != nil {
|
||||
log.Error("Error runner api getting task by ID: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error runner api getting task by ID")
|
||||
return nil, "", false
|
||||
}
|
||||
if task.Status != actions.StatusRunning {
|
||||
log.Error("Error runner api getting task: task is not running")
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error runner api getting task: task is not running")
|
||||
return nil, "", false
|
||||
}
|
||||
if err := task.LoadJob(ctx); err != nil {
|
||||
log.Error("Error runner api getting job: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error runner api getting job")
|
||||
return nil, "", false
|
||||
}
|
||||
return task, artifactName, true
|
||||
}
|
||||
|
||||
func (r *artifactV4Routes) getArtifactByName(ctx *ArtifactContext, runID int64, name string) (*actions.ActionArtifact, error) {
|
||||
var art actions.ActionArtifact
|
||||
has, err := db.GetEngine(ctx).Where("run_id = ? AND artifact_name = ? AND artifact_path = ? AND content_encoding = ?", runID, name, name+".zip", ArtifactV4ContentEncoding).Get(&art)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, util.ErrNotExist
|
||||
}
|
||||
return &art, nil
|
||||
}
|
||||
|
||||
func (r *artifactV4Routes) parseProtbufBody(ctx *ArtifactContext, req protoreflect.ProtoMessage) bool {
|
||||
body, err := io.ReadAll(ctx.Req.Body)
|
||||
if err != nil {
|
||||
log.Error("Error decode request body: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error decode request body")
|
||||
return false
|
||||
}
|
||||
err = protojson.Unmarshal(body, req)
|
||||
if err != nil {
|
||||
log.Error("Error decode request body: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error decode request body")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *artifactV4Routes) sendProtbufBody(ctx *ArtifactContext, req protoreflect.ProtoMessage) {
|
||||
resp, err := protojson.Marshal(req)
|
||||
if err != nil {
|
||||
log.Error("Error encode response body: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error encode response body")
|
||||
return
|
||||
}
|
||||
ctx.Resp.Header().Set("Content-Type", "application/json;charset=utf-8")
|
||||
ctx.Resp.WriteHeader(http.StatusOK)
|
||||
_, _ = ctx.Resp.Write(resp)
|
||||
}
|
||||
|
||||
func (r *artifactV4Routes) createArtifact(ctx *ArtifactContext) {
|
||||
var req CreateArtifactRequest
|
||||
|
||||
if ok := r.parseProtbufBody(ctx, &req); !ok {
|
||||
return
|
||||
}
|
||||
_, _, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
artifactName := req.Name
|
||||
|
||||
rententionDays := setting.Actions.ArtifactRetentionDays
|
||||
if req.ExpiresAt != nil {
|
||||
rententionDays = int64(time.Until(req.ExpiresAt.AsTime()).Hours() / 24)
|
||||
}
|
||||
// create or get artifact with name and path
|
||||
artifact, err := actions.CreateArtifact(ctx, ctx.ActionTask, artifactName, artifactName+".zip", rententionDays)
|
||||
if err != nil {
|
||||
log.Error("Error create or get artifact: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error create or get artifact")
|
||||
return
|
||||
}
|
||||
artifact.ContentEncoding = ArtifactV4ContentEncoding
|
||||
artifact.FileSize = 0
|
||||
artifact.FileCompressedSize = 0
|
||||
if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil {
|
||||
log.Error("Error UpdateArtifactByID: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error UpdateArtifactByID")
|
||||
return
|
||||
}
|
||||
|
||||
respData := CreateArtifactResponse{
|
||||
Ok: true,
|
||||
SignedUploadUrl: r.buildArtifactURL(ctx, "UploadArtifact", artifactName, ctx.ActionTask.ID, artifact.ID),
|
||||
}
|
||||
r.sendProtbufBody(ctx, &respData)
|
||||
}
|
||||
|
||||
func (r *artifactV4Routes) uploadArtifact(ctx *ArtifactContext) {
|
||||
task, artifactName, ok := r.verifySignature(ctx, "UploadArtifact")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
comp := ctx.Req.URL.Query().Get("comp")
|
||||
switch comp {
|
||||
case "block", "appendBlock":
|
||||
blockid := ctx.Req.URL.Query().Get("blockid")
|
||||
if blockid == "" {
|
||||
// get artifact by name
|
||||
artifact, err := r.getArtifactByName(ctx, task.Job.RunID, artifactName)
|
||||
if err != nil {
|
||||
log.Error("Error artifact not found: %v", err)
|
||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||
return
|
||||
}
|
||||
|
||||
_, err = appendUploadChunk(r.fs, ctx, artifact, artifact.FileSize, ctx.Req.ContentLength, artifact.RunID)
|
||||
if err != nil {
|
||||
log.Error("Error runner api getting task: task is not running")
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error runner api getting task: task is not running")
|
||||
return
|
||||
}
|
||||
artifact.FileCompressedSize += ctx.Req.ContentLength
|
||||
artifact.FileSize += ctx.Req.ContentLength
|
||||
if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil {
|
||||
log.Error("Error UpdateArtifactByID: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error UpdateArtifactByID")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
_, err := r.fs.Save(fmt.Sprintf("tmpv4%d/block-%d-%d-%s", task.Job.RunID, task.Job.RunID, ctx.Req.ContentLength, base64.URLEncoding.EncodeToString([]byte(blockid))), ctx.Req.Body, -1)
|
||||
if err != nil {
|
||||
log.Error("Error runner api getting task: task is not running")
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error runner api getting task: task is not running")
|
||||
return
|
||||
}
|
||||
}
|
||||
ctx.JSON(http.StatusCreated, "appended")
|
||||
case "blocklist":
|
||||
rawArtifactID := ctx.Req.URL.Query().Get("artifactID")
|
||||
artifactID, _ := strconv.ParseInt(rawArtifactID, 10, 64)
|
||||
_, err := r.fs.Save(fmt.Sprintf("tmpv4%d/%d-%d-blocklist", task.Job.RunID, task.Job.RunID, artifactID), ctx.Req.Body, -1)
|
||||
if err != nil {
|
||||
log.Error("Error runner api getting task: task is not running")
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error runner api getting task: task is not running")
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusCreated, "created")
|
||||
}
|
||||
}
|
||||
|
||||
type BlockList struct {
|
||||
Latest []string `xml:"Latest"`
|
||||
}
|
||||
|
||||
type Latest struct {
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
func (r *artifactV4Routes) readBlockList(runID, artifactID int64) (*BlockList, error) {
|
||||
blockListName := fmt.Sprintf("tmpv4%d/%d-%d-blocklist", runID, runID, artifactID)
|
||||
s, err := r.fs.Open(blockListName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
xdec := xml.NewDecoder(s)
|
||||
blockList := &BlockList{}
|
||||
err = xdec.Decode(blockList)
|
||||
|
||||
delerr := r.fs.Delete(blockListName)
|
||||
if delerr != nil {
|
||||
log.Warn("Failed to delete blockList %s: %v", blockListName, delerr)
|
||||
}
|
||||
return blockList, err
|
||||
}
|
||||
|
||||
func (r *artifactV4Routes) finalizeArtifact(ctx *ArtifactContext) {
|
||||
var req FinalizeArtifactRequest
|
||||
|
||||
if ok := r.parseProtbufBody(ctx, &req); !ok {
|
||||
return
|
||||
}
|
||||
_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// get artifact by name
|
||||
artifact, err := r.getArtifactByName(ctx, runID, req.Name)
|
||||
if err != nil {
|
||||
log.Error("Error artifact not found: %v", err)
|
||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||
return
|
||||
}
|
||||
|
||||
var chunks []*chunkFileItem
|
||||
blockList, err := r.readBlockList(runID, artifact.ID)
|
||||
if err != nil {
|
||||
log.Warn("Failed to read BlockList, fallback to old behavior: %v", err)
|
||||
chunkMap, err := listChunksByRunID(r.fs, runID)
|
||||
if err != nil {
|
||||
log.Error("Error merge chunks: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks")
|
||||
return
|
||||
}
|
||||
chunks, ok = chunkMap[artifact.ID]
|
||||
if !ok {
|
||||
log.Error("Error merge chunks")
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
chunks, err = listChunksByRunIDV4(r.fs, runID, artifact.ID, blockList)
|
||||
if err != nil {
|
||||
log.Error("Error merge chunks: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks")
|
||||
return
|
||||
}
|
||||
artifact.FileSize = chunks[len(chunks)-1].End + 1
|
||||
artifact.FileCompressedSize = chunks[len(chunks)-1].End + 1
|
||||
}
|
||||
|
||||
checksum := ""
|
||||
if req.Hash != nil {
|
||||
checksum = req.Hash.Value
|
||||
}
|
||||
if err := mergeChunksForArtifact(ctx, chunks, r.fs, artifact, checksum); err != nil {
|
||||
log.Error("Error merge chunks: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks")
|
||||
return
|
||||
}
|
||||
|
||||
respData := FinalizeArtifactResponse{
|
||||
Ok: true,
|
||||
ArtifactId: artifact.ID,
|
||||
}
|
||||
r.sendProtbufBody(ctx, &respData)
|
||||
}
|
||||
|
||||
func (r *artifactV4Routes) listArtifacts(ctx *ArtifactContext) {
|
||||
var req ListArtifactsRequest
|
||||
|
||||
if ok := r.parseProtbufBody(ctx, &req); !ok {
|
||||
return
|
||||
}
|
||||
_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{
|
||||
RunID: runID,
|
||||
Status: int(actions.ArtifactStatusUploadConfirmed),
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("Error getting artifacts: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
list := []*ListArtifactsResponse_MonolithArtifact{}
|
||||
|
||||
table := map[string]*ListArtifactsResponse_MonolithArtifact{}
|
||||
for _, artifact := range artifacts {
|
||||
if _, ok := table[artifact.ArtifactName]; ok || req.IdFilter != nil && artifact.ID != req.IdFilter.Value || req.NameFilter != nil && artifact.ArtifactName != req.NameFilter.Value || artifact.ArtifactName+".zip" != artifact.ArtifactPath || artifact.ContentEncoding != ArtifactV4ContentEncoding {
|
||||
table[artifact.ArtifactName] = nil
|
||||
continue
|
||||
}
|
||||
|
||||
table[artifact.ArtifactName] = &ListArtifactsResponse_MonolithArtifact{
|
||||
Name: artifact.ArtifactName,
|
||||
CreatedAt: timestamppb.New(artifact.CreatedUnix.AsTime()),
|
||||
DatabaseId: artifact.ID,
|
||||
WorkflowRunBackendId: req.WorkflowRunBackendId,
|
||||
WorkflowJobRunBackendId: req.WorkflowJobRunBackendId,
|
||||
Size: artifact.FileSize,
|
||||
}
|
||||
}
|
||||
for _, artifact := range table {
|
||||
if artifact != nil {
|
||||
list = append(list, artifact)
|
||||
}
|
||||
}
|
||||
|
||||
respData := ListArtifactsResponse{
|
||||
Artifacts: list,
|
||||
}
|
||||
r.sendProtbufBody(ctx, &respData)
|
||||
}
|
||||
|
||||
func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) {
|
||||
var req GetSignedArtifactURLRequest
|
||||
|
||||
if ok := r.parseProtbufBody(ctx, &req); !ok {
|
||||
return
|
||||
}
|
||||
_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
artifactName := req.Name
|
||||
|
||||
// get artifact by name
|
||||
artifact, err := r.getArtifactByName(ctx, runID, artifactName)
|
||||
if err != nil {
|
||||
log.Error("Error artifact not found: %v", err)
|
||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||
return
|
||||
}
|
||||
if artifact.Status != actions.ArtifactStatusUploadConfirmed {
|
||||
log.Error("Error artifact not found: %s", artifact.Status.ToString())
|
||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||
return
|
||||
}
|
||||
|
||||
respData := GetSignedArtifactURLResponse{}
|
||||
|
||||
if setting.Actions.ArtifactStorage.ServeDirect() {
|
||||
u, err := storage.ActionsArtifacts.URL(artifact.StoragePath, artifact.ArtifactPath, ctx.Req.Method, nil)
|
||||
if u != nil && err == nil {
|
||||
respData.SignedUrl = u.String()
|
||||
}
|
||||
}
|
||||
if respData.SignedUrl == "" {
|
||||
respData.SignedUrl = r.buildArtifactURL(ctx, "DownloadArtifact", artifactName, ctx.ActionTask.ID, artifact.ID)
|
||||
}
|
||||
r.sendProtbufBody(ctx, &respData)
|
||||
}
|
||||
|
||||
func (r *artifactV4Routes) downloadArtifact(ctx *ArtifactContext) {
|
||||
task, artifactName, ok := r.verifySignature(ctx, "DownloadArtifact")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// get artifact by name
|
||||
artifact, err := r.getArtifactByName(ctx, task.Job.RunID, artifactName)
|
||||
if err != nil {
|
||||
log.Error("Error artifact not found: %v", err)
|
||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||
return
|
||||
}
|
||||
if artifact.Status != actions.ArtifactStatusUploadConfirmed {
|
||||
log.Error("Error artifact not found: %s", artifact.Status.ToString())
|
||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||
return
|
||||
}
|
||||
|
||||
file, _ := r.fs.Open(artifact.StoragePath)
|
||||
|
||||
_, _ = io.Copy(ctx.Resp, file)
|
||||
}
|
||||
|
||||
func (r *artifactV4Routes) deleteArtifact(ctx *ArtifactContext) {
|
||||
var req DeleteArtifactRequest
|
||||
|
||||
if ok := r.parseProtbufBody(ctx, &req); !ok {
|
||||
return
|
||||
}
|
||||
_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// get artifact by name
|
||||
artifact, err := r.getArtifactByName(ctx, runID, req.Name)
|
||||
if err != nil {
|
||||
log.Error("Error artifact not found: %v", err)
|
||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||
return
|
||||
}
|
||||
|
||||
err = actions.SetArtifactNeedDelete(ctx, runID, req.Name)
|
||||
if err != nil {
|
||||
log.Error("Error deleting artifacts: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
respData := DeleteArtifactResponse{
|
||||
Ok: true,
|
||||
ArtifactId: artifact.ID,
|
||||
}
|
||||
r.sendProtbufBody(ctx, &respData)
|
||||
}
|
||||
36
routers/api/actions/ping/ping.go
Normal file
36
routers/api/actions/ping/ping.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package ping
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
|
||||
pingv1 "code.gitea.io/actions-proto-go/ping/v1"
|
||||
"code.gitea.io/actions-proto-go/ping/v1/pingv1connect"
|
||||
"connectrpc.com/connect"
|
||||
)
|
||||
|
||||
func NewPingServiceHandler() (string, http.Handler) {
|
||||
return pingv1connect.NewPingServiceHandler(&Service{})
|
||||
}
|
||||
|
||||
var _ pingv1connect.PingServiceHandler = (*Service)(nil)
|
||||
|
||||
type Service struct{}
|
||||
|
||||
func (s *Service) Ping(
|
||||
ctx context.Context,
|
||||
req *connect.Request[pingv1.PingRequest],
|
||||
) (*connect.Response[pingv1.PingResponse], error) {
|
||||
log.Trace("Content-Type: %s", req.Header().Get("Content-Type"))
|
||||
log.Trace("User-Agent: %s", req.Header().Get("User-Agent"))
|
||||
res := connect.NewResponse(&pingv1.PingResponse{
|
||||
Data: fmt.Sprintf("Hello, %s!", req.Msg.Data),
|
||||
})
|
||||
return res, nil
|
||||
}
|
||||
60
routers/api/actions/ping/ping_test.go
Normal file
60
routers/api/actions/ping/ping_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package ping
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
pingv1 "code.gitea.io/actions-proto-go/ping/v1"
|
||||
"code.gitea.io/actions-proto-go/ping/v1/pingv1connect"
|
||||
"connectrpc.com/connect"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestService(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle(pingv1connect.NewPingServiceHandler(
|
||||
&Service{},
|
||||
))
|
||||
MainServiceTest(t, mux)
|
||||
}
|
||||
|
||||
func MainServiceTest(t *testing.T, h http.Handler) {
|
||||
t.Parallel()
|
||||
server := httptest.NewUnstartedServer(h)
|
||||
server.EnableHTTP2 = true
|
||||
server.StartTLS()
|
||||
defer server.Close()
|
||||
|
||||
connectClient := pingv1connect.NewPingServiceClient(
|
||||
server.Client(),
|
||||
server.URL,
|
||||
)
|
||||
|
||||
grpcClient := pingv1connect.NewPingServiceClient(
|
||||
server.Client(),
|
||||
server.URL,
|
||||
connect.WithGRPC(),
|
||||
)
|
||||
|
||||
grpcWebClient := pingv1connect.NewPingServiceClient(
|
||||
server.Client(),
|
||||
server.URL,
|
||||
connect.WithGRPCWeb(),
|
||||
)
|
||||
|
||||
clients := []pingv1connect.PingServiceClient{connectClient, grpcClient, grpcWebClient}
|
||||
t.Run("ping request", func(t *testing.T) {
|
||||
for _, client := range clients {
|
||||
result, err := client.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{
|
||||
Data: "foobar",
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Hello, foobar!", result.Msg.Data)
|
||||
}
|
||||
})
|
||||
}
|
||||
80
routers/api/actions/runner/interceptor.go
Normal file
80
routers/api/actions/runner/interceptor.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const (
|
||||
uuidHeaderKey = "x-runner-uuid"
|
||||
tokenHeaderKey = "x-runner-token"
|
||||
)
|
||||
|
||||
var withRunner = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(unaryFunc connect.UnaryFunc) connect.UnaryFunc {
|
||||
return func(ctx context.Context, request connect.AnyRequest) (connect.AnyResponse, error) {
|
||||
methodName := getMethodName(request)
|
||||
if methodName == "Register" {
|
||||
return unaryFunc(ctx, request)
|
||||
}
|
||||
uuid := request.Header().Get(uuidHeaderKey)
|
||||
token := request.Header().Get(tokenHeaderKey)
|
||||
|
||||
runner, err := actions_model.GetRunnerByUUID(ctx, uuid)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
return nil, status.Error(codes.Unauthenticated, "unregistered runner")
|
||||
}
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(runner.TokenHash), []byte(auth_model.HashToken(token, runner.TokenSalt))) != 1 {
|
||||
return nil, status.Error(codes.Unauthenticated, "unregistered runner")
|
||||
}
|
||||
|
||||
cols := []string{"last_online"}
|
||||
runner.LastOnline = timeutil.TimeStampNow()
|
||||
if methodName == "UpdateTask" || methodName == "UpdateLog" {
|
||||
runner.LastActive = timeutil.TimeStampNow()
|
||||
cols = append(cols, "last_active")
|
||||
}
|
||||
if err := actions_model.UpdateRunner(ctx, runner, cols...); err != nil {
|
||||
log.Error("can't update runner status: %v", err)
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, runnerCtxKey{}, runner)
|
||||
return unaryFunc(ctx, request)
|
||||
}
|
||||
}))
|
||||
|
||||
func getMethodName(req connect.AnyRequest) string {
|
||||
splits := strings.Split(req.Spec().Procedure, "/")
|
||||
if len(splits) > 0 {
|
||||
return splits[len(splits)-1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type runnerCtxKey struct{}
|
||||
|
||||
func GetRunner(ctx context.Context) *actions_model.ActionRunner {
|
||||
if v := ctx.Value(runnerCtxKey{}); v != nil {
|
||||
if r, ok := v.(*actions_model.ActionRunner); ok {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
300
routers/api/actions/runner/runner.go
Normal file
300
routers/api/actions/runner/runner.go
Normal file
@@ -0,0 +1,300 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/actions"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
actions_service "code.gitea.io/gitea/services/actions"
|
||||
notify_service "code.gitea.io/gitea/services/notify"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
"code.gitea.io/actions-proto-go/runner/v1/runnerv1connect"
|
||||
"connectrpc.com/connect"
|
||||
gouuid "github.com/google/uuid"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func NewRunnerServiceHandler() (string, http.Handler) {
|
||||
return runnerv1connect.NewRunnerServiceHandler(
|
||||
&Service{},
|
||||
connect.WithCompressMinBytes(1024),
|
||||
withRunner,
|
||||
)
|
||||
}
|
||||
|
||||
var _ runnerv1connect.RunnerServiceClient = (*Service)(nil)
|
||||
|
||||
type Service struct{}
|
||||
|
||||
// Register for new runner.
|
||||
func (s *Service) Register(
|
||||
ctx context.Context,
|
||||
req *connect.Request[runnerv1.RegisterRequest],
|
||||
) (*connect.Response[runnerv1.RegisterResponse], error) {
|
||||
if req.Msg.Token == "" || req.Msg.Name == "" {
|
||||
return nil, errors.New("missing runner token, name")
|
||||
}
|
||||
|
||||
runnerToken, err := actions_model.GetRunnerToken(ctx, req.Msg.Token)
|
||||
if err != nil {
|
||||
return nil, errors.New("runner registration token not found")
|
||||
}
|
||||
|
||||
if !runnerToken.IsActive {
|
||||
return nil, errors.New("runner registration token has been invalidated, please use the latest one")
|
||||
}
|
||||
|
||||
if runnerToken.OwnerID > 0 {
|
||||
if _, err := user_model.GetUserByID(ctx, runnerToken.OwnerID); err != nil {
|
||||
return nil, errors.New("owner of the token not found")
|
||||
}
|
||||
}
|
||||
|
||||
if runnerToken.RepoID > 0 {
|
||||
if _, err := repo_model.GetRepositoryByID(ctx, runnerToken.RepoID); err != nil {
|
||||
return nil, errors.New("repository of the token not found")
|
||||
}
|
||||
}
|
||||
|
||||
labels := req.Msg.Labels
|
||||
|
||||
// create new runner
|
||||
name := util.EllipsisDisplayString(req.Msg.Name, 255)
|
||||
runner := &actions_model.ActionRunner{
|
||||
UUID: gouuid.New().String(),
|
||||
Name: name,
|
||||
OwnerID: runnerToken.OwnerID,
|
||||
RepoID: runnerToken.RepoID,
|
||||
Version: req.Msg.Version,
|
||||
AgentLabels: labels,
|
||||
Ephemeral: req.Msg.Ephemeral,
|
||||
}
|
||||
if err := runner.GenerateToken(); err != nil {
|
||||
return nil, errors.New("can't generate token")
|
||||
}
|
||||
|
||||
// create new runner
|
||||
if err := actions_model.CreateRunner(ctx, runner); err != nil {
|
||||
return nil, errors.New("can't create new runner")
|
||||
}
|
||||
|
||||
// update token status
|
||||
runnerToken.IsActive = true
|
||||
if err := actions_model.UpdateRunnerToken(ctx, runnerToken, "is_active"); err != nil {
|
||||
return nil, errors.New("can't update runner token status")
|
||||
}
|
||||
|
||||
res := connect.NewResponse(&runnerv1.RegisterResponse{
|
||||
Runner: &runnerv1.Runner{
|
||||
Id: runner.ID,
|
||||
Uuid: runner.UUID,
|
||||
Token: runner.Token,
|
||||
Name: runner.Name,
|
||||
Version: runner.Version,
|
||||
Labels: runner.AgentLabels,
|
||||
Ephemeral: runner.Ephemeral,
|
||||
},
|
||||
})
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *Service) Declare(
|
||||
ctx context.Context,
|
||||
req *connect.Request[runnerv1.DeclareRequest],
|
||||
) (*connect.Response[runnerv1.DeclareResponse], error) {
|
||||
runner := GetRunner(ctx)
|
||||
runner.AgentLabels = req.Msg.Labels
|
||||
runner.Version = req.Msg.Version
|
||||
if err := actions_model.UpdateRunner(ctx, runner, "agent_labels", "version"); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "update runner: %v", err)
|
||||
}
|
||||
|
||||
return connect.NewResponse(&runnerv1.DeclareResponse{
|
||||
Runner: &runnerv1.Runner{
|
||||
Id: runner.ID,
|
||||
Uuid: runner.UUID,
|
||||
Token: runner.Token,
|
||||
Name: runner.Name,
|
||||
Version: runner.Version,
|
||||
Labels: runner.AgentLabels,
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
|
||||
// FetchTask assigns a task to the runner
|
||||
func (s *Service) FetchTask(
|
||||
ctx context.Context,
|
||||
req *connect.Request[runnerv1.FetchTaskRequest],
|
||||
) (*connect.Response[runnerv1.FetchTaskResponse], error) {
|
||||
runner := GetRunner(ctx)
|
||||
|
||||
var task *runnerv1.Task
|
||||
tasksVersion := req.Msg.TasksVersion // task version from runner
|
||||
latestVersion, err := actions_model.GetTasksVersionByScope(ctx, runner.OwnerID, runner.RepoID)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "query tasks version failed: %v", err)
|
||||
} else if latestVersion == 0 {
|
||||
if err := actions_model.IncreaseTaskVersion(ctx, runner.OwnerID, runner.RepoID); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "fail to increase task version: %v", err)
|
||||
}
|
||||
// if we don't increase the value of `latestVersion` here,
|
||||
// the response of FetchTask will return tasksVersion as zero.
|
||||
// and the runner will treat it as an old version of Gitea.
|
||||
latestVersion++
|
||||
}
|
||||
|
||||
if tasksVersion != latestVersion {
|
||||
// if the task version in request is not equal to the version in db,
|
||||
// it means there may still be some tasks that haven't been assigned.
|
||||
// try to pick a task for the runner that send the request.
|
||||
if t, ok, err := actions_service.PickTask(ctx, runner); err != nil {
|
||||
log.Error("pick task failed: %v", err)
|
||||
return nil, status.Errorf(codes.Internal, "pick task: %v", err)
|
||||
} else if ok {
|
||||
task = t
|
||||
}
|
||||
}
|
||||
res := connect.NewResponse(&runnerv1.FetchTaskResponse{
|
||||
Task: task,
|
||||
TasksVersion: latestVersion,
|
||||
})
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// UpdateTask updates the task status.
|
||||
func (s *Service) UpdateTask(
|
||||
ctx context.Context,
|
||||
req *connect.Request[runnerv1.UpdateTaskRequest],
|
||||
) (*connect.Response[runnerv1.UpdateTaskResponse], error) {
|
||||
runner := GetRunner(ctx)
|
||||
|
||||
task, err := actions_model.UpdateTaskByState(ctx, runner.ID, req.Msg.State)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "update task: %v", err)
|
||||
}
|
||||
|
||||
for k, v := range req.Msg.Outputs {
|
||||
if len(k) > 255 {
|
||||
log.Warn("Ignore the output of task %d because the key is too long: %q", task.ID, k)
|
||||
continue
|
||||
}
|
||||
// The value can be a maximum of 1 MB
|
||||
if l := len(v); l > 1024*1024 {
|
||||
log.Warn("Ignore the output %q of task %d because the value is too long: %v", k, task.ID, l)
|
||||
continue
|
||||
}
|
||||
// There's another limitation on GitHub that the total of all outputs in a workflow run can be a maximum of 50 MB.
|
||||
// We don't check the total size here because it's not easy to do, and it doesn't really worth it.
|
||||
// See https://docs.github.com/en/actions/using-jobs/defining-outputs-for-jobs
|
||||
|
||||
if err := actions_model.InsertTaskOutputIfNotExist(ctx, task.ID, k, v); err != nil {
|
||||
log.Warn("Failed to insert the output %q of task %d: %v", k, task.ID, err)
|
||||
// It's ok not to return errors, the runner will resend the outputs.
|
||||
}
|
||||
}
|
||||
sentOutputs, err := actions_model.FindTaskOutputKeyByTaskID(ctx, task.ID)
|
||||
if err != nil {
|
||||
log.Warn("Failed to find the sent outputs of task %d: %v", task.ID, err)
|
||||
// It's not to return errors, it can be handled when the runner resends sent outputs.
|
||||
}
|
||||
|
||||
if err := task.LoadJob(ctx); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "load job: %v", err)
|
||||
}
|
||||
if err := task.Job.LoadAttributes(ctx); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "load run: %v", err)
|
||||
}
|
||||
|
||||
// don't create commit status for cron job
|
||||
if task.Job.Run.ScheduleID == 0 {
|
||||
actions_service.CreateCommitStatus(ctx, task.Job)
|
||||
}
|
||||
|
||||
if task.Status.IsDone() {
|
||||
notify_service.WorkflowJobStatusUpdate(ctx, task.Job.Run.Repo, task.Job.Run.TriggerUser, task.Job, task)
|
||||
}
|
||||
|
||||
if req.Msg.State.Result != runnerv1.Result_RESULT_UNSPECIFIED {
|
||||
if err := actions_service.EmitJobsIfReady(task.Job.RunID); err != nil {
|
||||
log.Error("Emit ready jobs of run %d: %v", task.Job.RunID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return connect.NewResponse(&runnerv1.UpdateTaskResponse{
|
||||
State: &runnerv1.TaskState{
|
||||
Id: req.Msg.State.Id,
|
||||
Result: task.Status.AsResult(),
|
||||
},
|
||||
SentOutputs: sentOutputs,
|
||||
}), nil
|
||||
}
|
||||
|
||||
// UpdateLog uploads log of the task.
|
||||
func (s *Service) UpdateLog(
|
||||
ctx context.Context,
|
||||
req *connect.Request[runnerv1.UpdateLogRequest],
|
||||
) (*connect.Response[runnerv1.UpdateLogResponse], error) {
|
||||
runner := GetRunner(ctx)
|
||||
|
||||
res := connect.NewResponse(&runnerv1.UpdateLogResponse{})
|
||||
|
||||
task, err := actions_model.GetTaskByID(ctx, req.Msg.TaskId)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "get task: %v", err)
|
||||
} else if runner.ID != task.RunnerID {
|
||||
return nil, status.Errorf(codes.Internal, "invalid runner for task")
|
||||
}
|
||||
ack := task.LogLength
|
||||
|
||||
if len(req.Msg.Rows) == 0 || req.Msg.Index > ack || int64(len(req.Msg.Rows))+req.Msg.Index <= ack {
|
||||
res.Msg.AckIndex = ack
|
||||
return res, nil
|
||||
}
|
||||
|
||||
if task.LogInStorage {
|
||||
return nil, status.Errorf(codes.AlreadyExists, "log file has been archived")
|
||||
}
|
||||
|
||||
rows := req.Msg.Rows[ack-req.Msg.Index:]
|
||||
ns, err := actions.WriteLogs(ctx, task.LogFilename, task.LogSize, rows)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "write logs: %v", err)
|
||||
}
|
||||
task.LogLength += int64(len(rows))
|
||||
for _, n := range ns {
|
||||
task.LogIndexes = append(task.LogIndexes, task.LogSize)
|
||||
task.LogSize += int64(n)
|
||||
}
|
||||
|
||||
res.Msg.AckIndex = task.LogLength
|
||||
|
||||
var remove func()
|
||||
if req.Msg.NoMore {
|
||||
task.LogInStorage = true
|
||||
remove, err = actions.TransferLogs(ctx, task.LogFilename)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "transfer logs: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := actions_model.UpdateTask(ctx, task, "log_indexes", "log_length", "log_size", "log_in_storage"); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "update task: %v", err)
|
||||
}
|
||||
if remove != nil {
|
||||
remove()
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
50
routers/api/packages/README.md
Normal file
50
routers/api/packages/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Gitea Package Registry
|
||||
|
||||
This document gives a brief overview how the package registry is organized in code.
|
||||
|
||||
## Structure
|
||||
|
||||
The package registry code is divided into multiple modules to split the functionality and make code reuse possible.
|
||||
|
||||
| Module | Description |
|
||||
| - | - |
|
||||
| `models/packages` | Common methods and models used by all registry types |
|
||||
| `models/packages/<type>` | Methods used by specific registry type. There should be no need to use type specific models. |
|
||||
| `modules/packages` | Common methods and types used by multiple registry types |
|
||||
| `modules/packages/<type>` | Registry type specific methods and types (e.g. metadata extraction of package files) |
|
||||
| `routers/api/packages` | Route definitions for all registry types |
|
||||
| `routers/api/packages/<type>` | Route implementation for a specific registry type |
|
||||
| `services/packages` | Helper methods used by registry types to handle common tasks like package creation and deletion in `routers` |
|
||||
| `services/packages/<type>` | Registry type specific methods used by `routers` and `services` |
|
||||
|
||||
## Models
|
||||
|
||||
Every package registry implementation uses the same underlying models:
|
||||
|
||||
| Model | Description |
|
||||
| - | - |
|
||||
| `Package` | The root of a package providing values fixed for every version (e.g. the package name) |
|
||||
| `PackageVersion` | A version of a package containing metadata (e.g. the package description) |
|
||||
| `PackageFile` | A file of a package describing its content (e.g. file name) |
|
||||
| `PackageBlob` | The content of a file (may be shared by multiple files) |
|
||||
| `PackageProperty` | Additional properties attached to `Package`, `PackageVersion` or `PackageFile` (e.g. used if metadata is needed for routing) |
|
||||
|
||||
The following diagram shows the relationship between the models:
|
||||
```
|
||||
Package <1---*> PackageVersion <1---*> PackageFile <*---1> PackageBlob
|
||||
```
|
||||
|
||||
## Adding a new package registry type
|
||||
|
||||
Before adding a new package registry type have a look at the existing implementation to get an impression of how it could work.
|
||||
Most registry types offer endpoints to retrieve the metadata, upload and download package files.
|
||||
The upload endpoint is often the heavy part because it must validate the uploaded blob, extract metadata and create the models.
|
||||
The methods to validate and extract the metadata should be added in the `modules/packages/<type>` package.
|
||||
If the upload is valid the methods in `services/packages` allow to store the upload and create the corresponding models.
|
||||
It depends if the registry type allows multiple files per package version which method should be called:
|
||||
- `CreatePackageAndAddFile`: error if package version already exists
|
||||
- `CreatePackageOrAddFileToExisting`: error if file already exists
|
||||
- `AddFileToExistingPackage`: error if package version does not exist or file already exists
|
||||
|
||||
`services/packages` also contains helper methods to download a file or to remove a package version.
|
||||
There are no helper methods for metadata endpoints because they are very type specific.
|
||||
265
routers/api/packages/alpine/alpine.go
Normal file
265
routers/api/packages/alpine/alpine.go
Normal file
@@ -0,0 +1,265 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package alpine
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
packages_module "code.gitea.io/gitea/modules/packages"
|
||||
alpine_module "code.gitea.io/gitea/modules/packages/alpine"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/routers/api/packages/helper"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
alpine_service "code.gitea.io/gitea/services/packages/alpine"
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
ctx.PlainText(status, message)
|
||||
}
|
||||
|
||||
func GetRepositoryKey(ctx *context.Context) {
|
||||
_, pub, err := alpine_service.GetOrCreateKeyPair(ctx, ctx.Package.Owner.ID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
pubPem, _ := pem.Decode([]byte(pub))
|
||||
if pubPem == nil {
|
||||
apiError(ctx, http.StatusInternalServerError, "failed to decode private key pem")
|
||||
return
|
||||
}
|
||||
|
||||
pubKey, err := x509.ParsePKIXPublicKey(pubPem.Bytes)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
fingerprint, err := util.CreatePublicKeyFingerprint(pubKey)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.ServeContent(strings.NewReader(pub), &context.ServeHeaderOptions{
|
||||
ContentType: "application/x-pem-file",
|
||||
Filename: fmt.Sprintf("%s@%s.rsa.pub", ctx.Package.Owner.LowerName, hex.EncodeToString(fingerprint)),
|
||||
})
|
||||
}
|
||||
|
||||
func GetRepositoryFile(ctx *context.Context) {
|
||||
pv, err := alpine_service.GetOrCreateRepositoryVersion(ctx, ctx.Package.Owner.ID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion(
|
||||
ctx,
|
||||
pv,
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: alpine_service.IndexArchiveFilename,
|
||||
CompositeKey: fmt.Sprintf("%s|%s|%s", ctx.PathParam("branch"), ctx.PathParam("repository"), ctx.PathParam("architecture")),
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
|
||||
func UploadPackageFile(ctx *context.Context) {
|
||||
branch := strings.TrimSpace(ctx.PathParam("branch"))
|
||||
repository := strings.TrimSpace(ctx.PathParam("repository"))
|
||||
if branch == "" || repository == "" {
|
||||
apiError(ctx, http.StatusBadRequest, "invalid branch or repository")
|
||||
return
|
||||
}
|
||||
|
||||
upload, needToClose, err := ctx.UploadStream()
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if needToClose {
|
||||
defer upload.Close()
|
||||
}
|
||||
|
||||
buf, err := packages_module.CreateHashedBufferFromReader(upload)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer buf.Close()
|
||||
|
||||
pck, err := alpine_module.ParsePackage(buf)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) || errors.Is(err, io.EOF) {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := buf.Seek(0, io.SeekStart); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
fileMetadataRaw, err := json.Marshal(pck.FileMetadata)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
_, _, err = packages_service.CreatePackageOrAddFileToExisting(
|
||||
ctx,
|
||||
&packages_service.PackageCreationInfo{
|
||||
PackageInfo: packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeAlpine,
|
||||
Name: pck.Name,
|
||||
Version: pck.Version,
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Metadata: pck.VersionMetadata,
|
||||
},
|
||||
&packages_service.PackageFileCreationInfo{
|
||||
PackageFileInfo: packages_service.PackageFileInfo{
|
||||
Filename: fmt.Sprintf("%s-%s.apk", pck.Name, pck.Version),
|
||||
CompositeKey: fmt.Sprintf("%s|%s|%s", branch, repository, pck.FileMetadata.Architecture),
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Data: buf,
|
||||
IsLead: true,
|
||||
Properties: map[string]string{
|
||||
alpine_module.PropertyBranch: branch,
|
||||
alpine_module.PropertyRepository: repository,
|
||||
alpine_module.PropertyArchitecture: pck.FileMetadata.Architecture,
|
||||
alpine_module.PropertyMetadata: string(fileMetadataRaw),
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case packages_model.ErrDuplicatePackageVersion, packages_model.ErrDuplicatePackageFile:
|
||||
apiError(ctx, http.StatusConflict, err)
|
||||
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
default:
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := alpine_service.BuildSpecificRepositoryFiles(ctx, ctx.Package.Owner.ID, branch, repository, pck.FileMetadata.Architecture); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusCreated)
|
||||
}
|
||||
|
||||
func DownloadPackageFile(ctx *context.Context) {
|
||||
branch := ctx.PathParam("branch")
|
||||
repository := ctx.PathParam("repository")
|
||||
architecture := ctx.PathParam("architecture")
|
||||
|
||||
opts := &packages_model.PackageFileSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
PackageType: packages_model.TypeAlpine,
|
||||
Query: ctx.PathParam("filename"),
|
||||
CompositeKey: fmt.Sprintf("%s|%s|%s", branch, repository, architecture),
|
||||
}
|
||||
pfs, _, err := packages_model.SearchFiles(ctx, opts)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pfs) == 0 {
|
||||
// Try again with architecture 'noarch'
|
||||
if architecture == alpine_module.NoArch {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
opts.CompositeKey = fmt.Sprintf("%s|%s|%s", branch, repository, alpine_module.NoArch)
|
||||
if pfs, _, err = packages_model.SearchFiles(ctx, opts); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(pfs) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownload(ctx, pfs[0], ctx.Req.Method)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
|
||||
func DeletePackageFile(ctx *context.Context) {
|
||||
branch, repository, architecture := ctx.PathParam("branch"), ctx.PathParam("repository"), ctx.PathParam("architecture")
|
||||
|
||||
pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
PackageType: packages_model.TypeAlpine,
|
||||
Query: ctx.PathParam("filename"),
|
||||
CompositeKey: fmt.Sprintf("%s|%s|%s", branch, repository, architecture),
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pfs) != 1 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := packages_service.RemovePackageFileAndVersionIfUnreferenced(ctx, ctx.Doer, pfs[0]); err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := alpine_service.BuildSpecificRepositoryFiles(ctx, ctx.Package.Owner.ID, branch, repository, architecture); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
574
routers/api/packages/api.go
Normal file
574
routers/api/packages/api.go
Normal file
@@ -0,0 +1,574 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package packages
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/routers/api/packages/alpine"
|
||||
"code.gitea.io/gitea/routers/api/packages/arch"
|
||||
"code.gitea.io/gitea/routers/api/packages/cargo"
|
||||
"code.gitea.io/gitea/routers/api/packages/chef"
|
||||
"code.gitea.io/gitea/routers/api/packages/composer"
|
||||
"code.gitea.io/gitea/routers/api/packages/conan"
|
||||
"code.gitea.io/gitea/routers/api/packages/conda"
|
||||
"code.gitea.io/gitea/routers/api/packages/container"
|
||||
"code.gitea.io/gitea/routers/api/packages/cran"
|
||||
"code.gitea.io/gitea/routers/api/packages/debian"
|
||||
"code.gitea.io/gitea/routers/api/packages/generic"
|
||||
"code.gitea.io/gitea/routers/api/packages/goproxy"
|
||||
"code.gitea.io/gitea/routers/api/packages/helm"
|
||||
"code.gitea.io/gitea/routers/api/packages/maven"
|
||||
"code.gitea.io/gitea/routers/api/packages/npm"
|
||||
"code.gitea.io/gitea/routers/api/packages/nuget"
|
||||
"code.gitea.io/gitea/routers/api/packages/pub"
|
||||
"code.gitea.io/gitea/routers/api/packages/pypi"
|
||||
"code.gitea.io/gitea/routers/api/packages/rpm"
|
||||
"code.gitea.io/gitea/routers/api/packages/rubygems"
|
||||
"code.gitea.io/gitea/routers/api/packages/swift"
|
||||
"code.gitea.io/gitea/routers/api/packages/vagrant"
|
||||
"code.gitea.io/gitea/services/auth"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
func reqPackageAccess(accessMode perm.AccessMode) func(ctx *context.Context) {
|
||||
return func(ctx *context.Context) {
|
||||
if ctx.Data["IsApiToken"] == true {
|
||||
scope, ok := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope)
|
||||
if ok { // it's a personal access token but not oauth2 token
|
||||
scopeMatched := false
|
||||
var err error
|
||||
switch accessMode {
|
||||
case perm.AccessModeRead:
|
||||
scopeMatched, err = scope.HasScope(auth_model.AccessTokenScopeReadPackage)
|
||||
if err != nil {
|
||||
ctx.HTTPError(http.StatusInternalServerError, "HasScope", err.Error())
|
||||
return
|
||||
}
|
||||
case perm.AccessModeWrite:
|
||||
scopeMatched, err = scope.HasScope(auth_model.AccessTokenScopeWritePackage)
|
||||
if err != nil {
|
||||
ctx.HTTPError(http.StatusInternalServerError, "HasScope", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if !scopeMatched {
|
||||
ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="Gitea Package API"`)
|
||||
ctx.HTTPError(http.StatusUnauthorized, "reqPackageAccess", "user should have specific permission or be a site admin")
|
||||
return
|
||||
}
|
||||
|
||||
// check if scope only applies to public resources
|
||||
publicOnly, err := scope.PublicOnly()
|
||||
if err != nil {
|
||||
ctx.HTTPError(http.StatusForbidden, "tokenRequiresScope", "parsing public resource scope failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if publicOnly {
|
||||
if ctx.Package != nil && ctx.Package.Owner.Visibility.IsPrivate() {
|
||||
ctx.HTTPError(http.StatusForbidden, "reqToken", "token scope is limited to public packages")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ctx.Package.AccessMode < accessMode && !ctx.IsUserSiteAdmin() {
|
||||
ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="Gitea Package API"`)
|
||||
ctx.HTTPError(http.StatusUnauthorized, "reqPackageAccess", "user should have specific permission or be a site admin")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func verifyAuth(r *web.Router, authMethods []auth.Method) {
|
||||
if setting.Service.EnableReverseProxyAuth {
|
||||
authMethods = append(authMethods, &auth.ReverseProxy{})
|
||||
}
|
||||
authGroup := auth.NewGroup(authMethods...)
|
||||
|
||||
r.Use(func(ctx *context.Context) {
|
||||
var err error
|
||||
ctx.Doer, err = authGroup.Verify(ctx.Req, ctx.Resp, ctx, ctx.Session)
|
||||
if err != nil {
|
||||
log.Error("Failed to verify user: %v", err)
|
||||
ctx.HTTPError(http.StatusUnauthorized, "Failed to authenticate user")
|
||||
return
|
||||
}
|
||||
ctx.IsSigned = ctx.Doer != nil
|
||||
})
|
||||
}
|
||||
|
||||
// CommonRoutes provide endpoints for most package managers (except containers - see below)
|
||||
// These are mounted on `/api/packages` (not `/api/v1/packages`)
|
||||
func CommonRoutes() *web.Router {
|
||||
r := web.NewRouter()
|
||||
|
||||
r.Use(context.PackageContexter())
|
||||
|
||||
verifyAuth(r, []auth.Method{
|
||||
&auth.OAuth2{},
|
||||
&auth.Basic{},
|
||||
&nuget.Auth{},
|
||||
&conan.Auth{},
|
||||
&chef.Auth{},
|
||||
})
|
||||
|
||||
r.Group("/{username}", func() {
|
||||
r.Group("/alpine", func() {
|
||||
r.Get("/key", alpine.GetRepositoryKey)
|
||||
r.Group("/{branch}/{repository}", func() {
|
||||
r.Put("", reqPackageAccess(perm.AccessModeWrite), alpine.UploadPackageFile)
|
||||
r.Group("/{architecture}", func() {
|
||||
r.Get("/APKINDEX.tar.gz", alpine.GetRepositoryFile)
|
||||
r.Group("/{filename}", func() {
|
||||
r.Get("", alpine.DownloadPackageFile)
|
||||
r.Delete("", reqPackageAccess(perm.AccessModeWrite), alpine.DeletePackageFile)
|
||||
})
|
||||
})
|
||||
})
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
r.Group("/arch", func() {
|
||||
r.Methods("HEAD,GET", "/repository.key", arch.GetRepositoryKey)
|
||||
r.Methods("PUT", "" /* no repository */, reqPackageAccess(perm.AccessModeWrite), arch.UploadPackageFile)
|
||||
r.PathGroup("/*", func(g *web.RouterPathGroup) {
|
||||
g.MatchPath("PUT", "/<repository:*>", reqPackageAccess(perm.AccessModeWrite), arch.UploadPackageFile)
|
||||
g.MatchPath("HEAD,GET", "/<repository:*>/<architecture>/<filename>", arch.GetPackageOrRepositoryFile)
|
||||
g.MatchPath("DELETE", "/<repository:*>/<name>/<version>/<architecture>", reqPackageAccess(perm.AccessModeWrite), arch.DeletePackageVersion)
|
||||
})
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
r.Group("/cargo", func() {
|
||||
r.Group("/api/v1/crates", func() {
|
||||
r.Get("", cargo.SearchPackages)
|
||||
r.Put("/new", reqPackageAccess(perm.AccessModeWrite), cargo.UploadPackage)
|
||||
r.Group("/{package}", func() {
|
||||
r.Group("/{version}", func() {
|
||||
r.Get("/download", cargo.DownloadPackageFile)
|
||||
r.Delete("/yank", reqPackageAccess(perm.AccessModeWrite), cargo.YankPackage)
|
||||
r.Put("/unyank", reqPackageAccess(perm.AccessModeWrite), cargo.UnyankPackage)
|
||||
})
|
||||
r.Get("/owners", cargo.ListOwners)
|
||||
})
|
||||
})
|
||||
r.Get("/config.json", cargo.RepositoryConfig)
|
||||
r.Get("/1/{package}", cargo.EnumeratePackageVersions)
|
||||
r.Get("/2/{package}", cargo.EnumeratePackageVersions)
|
||||
// Use dummy placeholders because these parts are not of interest
|
||||
r.Get("/3/{_}/{package}", cargo.EnumeratePackageVersions)
|
||||
r.Get("/{_}/{__}/{package}", cargo.EnumeratePackageVersions)
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
r.Group("/chef", func() {
|
||||
r.Group("/api/v1", func() {
|
||||
r.Get("/universe", chef.PackagesUniverse)
|
||||
r.Get("/search", chef.EnumeratePackages)
|
||||
r.Group("/cookbooks", func() {
|
||||
r.Get("", chef.EnumeratePackages)
|
||||
r.Post("", reqPackageAccess(perm.AccessModeWrite), chef.UploadPackage)
|
||||
r.Group("/{name}", func() {
|
||||
r.Get("", chef.PackageMetadata)
|
||||
r.Group("/versions/{version}", func() {
|
||||
r.Get("", chef.PackageVersionMetadata)
|
||||
r.Delete("", reqPackageAccess(perm.AccessModeWrite), chef.DeletePackageVersion)
|
||||
r.Get("/download", chef.DownloadPackage)
|
||||
})
|
||||
r.Delete("", reqPackageAccess(perm.AccessModeWrite), chef.DeletePackage)
|
||||
})
|
||||
})
|
||||
})
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
r.Group("/composer", func() {
|
||||
r.Get("/packages.json", composer.ServiceIndex)
|
||||
r.Get("/search.json", composer.SearchPackages)
|
||||
r.Get("/list.json", composer.EnumeratePackages)
|
||||
r.Get("/p2/{vendorname}/{projectname}~dev.json", composer.PackageMetadata)
|
||||
r.Get("/p2/{vendorname}/{projectname}.json", composer.PackageMetadata)
|
||||
r.Get("/files/{package}/{version}/{filename}", composer.DownloadPackageFile)
|
||||
r.Put("", reqPackageAccess(perm.AccessModeWrite), composer.UploadPackage)
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
r.Group("/conan", func() {
|
||||
r.Group("/v1", func() {
|
||||
r.Get("/ping", conan.Ping)
|
||||
r.Group("/users", func() {
|
||||
r.Get("/authenticate", conan.Authenticate)
|
||||
r.Get("/check_credentials", conan.CheckCredentials)
|
||||
})
|
||||
r.Group("/conans", func() {
|
||||
r.Get("/search", conan.SearchRecipes)
|
||||
r.Group("/{name}/{version}/{user}/{channel}", func() {
|
||||
r.Get("", conan.RecipeSnapshot)
|
||||
r.Delete("", reqPackageAccess(perm.AccessModeWrite), conan.DeleteRecipeV1)
|
||||
r.Get("/search", conan.SearchPackagesV1)
|
||||
r.Get("/digest", conan.RecipeDownloadURLs)
|
||||
r.Post("/upload_urls", reqPackageAccess(perm.AccessModeWrite), conan.RecipeUploadURLs)
|
||||
r.Get("/download_urls", conan.RecipeDownloadURLs)
|
||||
r.Group("/packages", func() {
|
||||
r.Post("/delete", reqPackageAccess(perm.AccessModeWrite), conan.DeletePackageV1)
|
||||
r.Group("/{package_reference}", func() {
|
||||
r.Get("", conan.PackageSnapshot)
|
||||
r.Get("/digest", conan.PackageDownloadURLs)
|
||||
r.Post("/upload_urls", reqPackageAccess(perm.AccessModeWrite), conan.PackageUploadURLs)
|
||||
r.Get("/download_urls", conan.PackageDownloadURLs)
|
||||
})
|
||||
})
|
||||
}, conan.ExtractPathParameters)
|
||||
})
|
||||
r.Group("/files/{name}/{version}/{user}/{channel}/{recipe_revision}", func() {
|
||||
r.Group("/recipe/{filename}", func() {
|
||||
r.Get("", conan.DownloadRecipeFile)
|
||||
r.Put("", reqPackageAccess(perm.AccessModeWrite), conan.UploadRecipeFile)
|
||||
})
|
||||
r.Group("/package/{package_reference}/{package_revision}/{filename}", func() {
|
||||
r.Get("", conan.DownloadPackageFile)
|
||||
r.Put("", reqPackageAccess(perm.AccessModeWrite), conan.UploadPackageFile)
|
||||
})
|
||||
}, conan.ExtractPathParameters)
|
||||
})
|
||||
r.Group("/v2", func() {
|
||||
r.Get("/ping", conan.Ping)
|
||||
r.Group("/users", func() {
|
||||
r.Get("/authenticate", conan.Authenticate)
|
||||
r.Get("/check_credentials", conan.CheckCredentials)
|
||||
})
|
||||
r.Group("/conans", func() {
|
||||
r.Get("/search", conan.SearchRecipes)
|
||||
r.Group("/{name}/{version}/{user}/{channel}", func() {
|
||||
r.Delete("", reqPackageAccess(perm.AccessModeWrite), conan.DeleteRecipeV2)
|
||||
r.Get("/search", conan.SearchPackagesV2)
|
||||
r.Get("/latest", conan.LatestRecipeRevision)
|
||||
r.Group("/revisions", func() {
|
||||
r.Get("", conan.ListRecipeRevisions)
|
||||
r.Group("/{recipe_revision}", func() {
|
||||
r.Delete("", reqPackageAccess(perm.AccessModeWrite), conan.DeleteRecipeV2)
|
||||
r.Get("/search", conan.SearchPackagesV2)
|
||||
r.Group("/files", func() {
|
||||
r.Get("", conan.ListRecipeRevisionFiles)
|
||||
r.Group("/{filename}", func() {
|
||||
r.Get("", conan.DownloadRecipeFile)
|
||||
r.Put("", reqPackageAccess(perm.AccessModeWrite), conan.UploadRecipeFile)
|
||||
})
|
||||
})
|
||||
r.Group("/packages", func() {
|
||||
r.Delete("", reqPackageAccess(perm.AccessModeWrite), conan.DeletePackageV2)
|
||||
r.Group("/{package_reference}", func() {
|
||||
r.Delete("", reqPackageAccess(perm.AccessModeWrite), conan.DeletePackageV2)
|
||||
r.Get("/latest", conan.LatestPackageRevision)
|
||||
r.Group("/revisions", func() {
|
||||
r.Get("", conan.ListPackageRevisions)
|
||||
r.Group("/{package_revision}", func() {
|
||||
r.Delete("", reqPackageAccess(perm.AccessModeWrite), conan.DeletePackageV2)
|
||||
r.Group("/files", func() {
|
||||
r.Get("", conan.ListPackageRevisionFiles)
|
||||
r.Group("/{filename}", func() {
|
||||
r.Get("", conan.DownloadPackageFile)
|
||||
r.Put("", reqPackageAccess(perm.AccessModeWrite), conan.UploadPackageFile)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}, conan.ExtractPathParameters)
|
||||
})
|
||||
})
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
r.PathGroup("/conda/*", func(g *web.RouterPathGroup) {
|
||||
g.MatchPath("GET", "/<architecture>/<filename>", conda.ListOrGetPackages)
|
||||
g.MatchPath("GET", "/<channel:*>/<architecture>/<filename>", conda.ListOrGetPackages)
|
||||
g.MatchPath("PUT", "/<channel:*>/<filename>", reqPackageAccess(perm.AccessModeWrite), conda.UploadPackageFile)
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
r.Group("/cran", func() {
|
||||
r.Group("/src", func() {
|
||||
r.Group("/contrib", func() {
|
||||
r.Get("/PACKAGES", cran.EnumerateSourcePackages)
|
||||
r.Get("/PACKAGES{format}", cran.EnumerateSourcePackages)
|
||||
r.Get("/{filename}", cran.DownloadSourcePackageFile)
|
||||
r.Get("/Archive/{packagename}/{filename}", cran.DownloadSourcePackageFile)
|
||||
})
|
||||
r.Put("", reqPackageAccess(perm.AccessModeWrite), cran.UploadSourcePackageFile)
|
||||
})
|
||||
r.Group("/bin", func() {
|
||||
r.Group("/{platform}/contrib/{rversion}", func() {
|
||||
r.Get("/PACKAGES", cran.EnumerateBinaryPackages)
|
||||
r.Get("/PACKAGES{format}", cran.EnumerateBinaryPackages)
|
||||
r.Get("/{filename}", cran.DownloadBinaryPackageFile)
|
||||
})
|
||||
r.Put("", reqPackageAccess(perm.AccessModeWrite), cran.UploadBinaryPackageFile)
|
||||
})
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
r.Group("/debian", func() {
|
||||
r.Get("/repository.key", debian.GetRepositoryKey)
|
||||
r.Group("/dists/{distribution}", func() {
|
||||
r.Get("/{filename}", debian.GetRepositoryFile)
|
||||
r.Get("/by-hash/{algorithm}/{hash}", debian.GetRepositoryFileByHash)
|
||||
r.Group("/{component}/{architecture}", func() {
|
||||
r.Get("/{filename}", debian.GetRepositoryFile)
|
||||
r.Get("/by-hash/{algorithm}/{hash}", debian.GetRepositoryFileByHash)
|
||||
})
|
||||
})
|
||||
r.Group("/pool/{distribution}/{component}", func() {
|
||||
r.Get("/{name}_{version}_{architecture}.deb", debian.DownloadPackageFile)
|
||||
r.Group("", func() {
|
||||
r.Put("/upload", debian.UploadPackageFile)
|
||||
r.Delete("/{name}/{version}/{architecture}", debian.DeletePackageFile)
|
||||
}, reqPackageAccess(perm.AccessModeWrite))
|
||||
})
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
r.Group("/go", func() {
|
||||
r.Put("/upload", reqPackageAccess(perm.AccessModeWrite), goproxy.UploadPackage)
|
||||
r.Get("/sumdb/sum.golang.org/supported", http.NotFound)
|
||||
|
||||
// https://go.dev/ref/mod#goproxy-protocol
|
||||
r.PathGroup("/*", func(g *web.RouterPathGroup) {
|
||||
g.MatchPath("GET", "/<name:*>/@<version:latest>", goproxy.PackageVersionMetadata)
|
||||
g.MatchPath("GET", "/<name:*>/@v/list", goproxy.EnumeratePackageVersions)
|
||||
g.MatchPath("GET", "/<name:*>/@v/<version>.zip", goproxy.DownloadPackageFile)
|
||||
g.MatchPath("GET", "/<name:*>/@v/<version>.info", goproxy.PackageVersionMetadata)
|
||||
g.MatchPath("GET", "/<name:*>/@v/<version>.mod", goproxy.PackageVersionGoModContent)
|
||||
})
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
r.Group("/generic", func() {
|
||||
r.Group("/{packagename}/{packageversion}", func() {
|
||||
r.Delete("", reqPackageAccess(perm.AccessModeWrite), generic.DeletePackage)
|
||||
r.Group("/{filename}", func() {
|
||||
r.Methods("HEAD,GET", "", generic.DownloadPackageFile)
|
||||
r.Group("", func() {
|
||||
r.Put("", generic.UploadPackage)
|
||||
r.Delete("", generic.DeletePackageFile)
|
||||
}, reqPackageAccess(perm.AccessModeWrite))
|
||||
})
|
||||
})
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
r.Group("/helm", func() {
|
||||
r.Get("/index.yaml", helm.Index)
|
||||
r.Get("/{filename}", helm.DownloadPackageFile)
|
||||
r.Post("/api/charts", reqPackageAccess(perm.AccessModeWrite), helm.UploadPackage)
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
r.Group("/maven", func() {
|
||||
r.Put("/*", reqPackageAccess(perm.AccessModeWrite), maven.UploadPackageFile)
|
||||
r.Get("/*", maven.DownloadPackageFile)
|
||||
r.Head("/*", maven.ProvidePackageFileHeader)
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
r.Group("/nuget", func() {
|
||||
r.Group("", func() { // Needs to be unauthenticated for the NuGet client.
|
||||
r.Get("/", nuget.ServiceIndexV2)
|
||||
r.Get("/index.json", nuget.ServiceIndexV3)
|
||||
r.Get("/$metadata", nuget.FeedCapabilityResource)
|
||||
})
|
||||
r.Group("", func() {
|
||||
r.Get("/query", nuget.SearchServiceV3)
|
||||
r.Group("/registration/{id}", func() {
|
||||
r.Get("/index.json", nuget.RegistrationIndex)
|
||||
r.Get("/{version}", nuget.RegistrationLeafV3)
|
||||
})
|
||||
r.Group("/package/{id}", func() {
|
||||
r.Get("/index.json", nuget.EnumeratePackageVersionsV3)
|
||||
r.Get("/{version}/{filename}", nuget.DownloadPackageFile)
|
||||
})
|
||||
r.Group("", func() {
|
||||
r.Put("/", nuget.UploadPackage)
|
||||
r.Put("/symbolpackage", nuget.UploadSymbolPackage)
|
||||
r.Delete("/{id}/{version}", nuget.DeletePackage)
|
||||
}, reqPackageAccess(perm.AccessModeWrite))
|
||||
r.Get("/symbols/{filename}/{guid:[0-9a-fA-F]{32}[fF]{8}}/{filename2}", nuget.DownloadSymbolFile)
|
||||
r.Get("/Packages(Id='{id:[^']+}',Version='{version:[^']+}')", nuget.RegistrationLeafV2)
|
||||
r.Group("/Packages()", func() {
|
||||
r.Get("", nuget.SearchServiceV2)
|
||||
r.Get("/$count", nuget.SearchServiceV2Count)
|
||||
})
|
||||
r.Group("/FindPackagesById()", func() {
|
||||
r.Get("", nuget.EnumeratePackageVersionsV2)
|
||||
r.Get("/$count", nuget.EnumeratePackageVersionsV2Count)
|
||||
})
|
||||
r.Group("/Search()", func() {
|
||||
r.Get("", nuget.SearchServiceV2)
|
||||
r.Get("/$count", nuget.SearchServiceV2Count)
|
||||
})
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
})
|
||||
r.Group("/npm", func() {
|
||||
r.Group("/@{scope}/{id}", func() {
|
||||
r.Get("", npm.PackageMetadata)
|
||||
r.Put("", reqPackageAccess(perm.AccessModeWrite), npm.UploadPackage)
|
||||
r.Group("/-/{version}/{filename}", func() {
|
||||
r.Get("", npm.DownloadPackageFile)
|
||||
r.Delete("/-rev/{revision}", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion)
|
||||
})
|
||||
r.Get("/-/{filename}", npm.DownloadPackageFileByName)
|
||||
r.Group("/-rev/{revision}", func() {
|
||||
r.Delete("", npm.DeletePackage)
|
||||
r.Put("", npm.DeletePreview)
|
||||
}, reqPackageAccess(perm.AccessModeWrite))
|
||||
})
|
||||
r.Group("/{id}", func() {
|
||||
r.Get("", npm.PackageMetadata)
|
||||
r.Put("", reqPackageAccess(perm.AccessModeWrite), npm.UploadPackage)
|
||||
r.Group("/-/{version}/{filename}", func() {
|
||||
r.Get("", npm.DownloadPackageFile)
|
||||
r.Delete("/-rev/{revision}", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion)
|
||||
})
|
||||
r.Get("/-/{filename}", npm.DownloadPackageFileByName)
|
||||
r.Group("/-rev/{revision}", func() {
|
||||
r.Delete("", npm.DeletePackage)
|
||||
r.Put("", npm.DeletePreview)
|
||||
}, reqPackageAccess(perm.AccessModeWrite))
|
||||
})
|
||||
r.Group("/-/package/@{scope}/{id}/dist-tags", func() {
|
||||
r.Get("", npm.ListPackageTags)
|
||||
r.Group("/{tag}", func() {
|
||||
r.Put("", npm.AddPackageTag)
|
||||
r.Delete("", npm.DeletePackageTag)
|
||||
}, reqPackageAccess(perm.AccessModeWrite))
|
||||
})
|
||||
r.Group("/-/package/{id}/dist-tags", func() {
|
||||
r.Get("", npm.ListPackageTags)
|
||||
r.Group("/{tag}", func() {
|
||||
r.Put("", npm.AddPackageTag)
|
||||
r.Delete("", npm.DeletePackageTag)
|
||||
}, reqPackageAccess(perm.AccessModeWrite))
|
||||
})
|
||||
r.Group("/-/v1/search", func() {
|
||||
r.Get("", npm.PackageSearch)
|
||||
})
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
r.Group("/pub", func() {
|
||||
r.Group("/api/packages", func() {
|
||||
r.Group("/versions/new", func() {
|
||||
r.Get("", pub.RequestUpload)
|
||||
r.Post("/upload", pub.UploadPackageFile)
|
||||
r.Get("/finalize/{id}/{version}", pub.FinalizePackage)
|
||||
}, reqPackageAccess(perm.AccessModeWrite))
|
||||
r.Group("/{id}", func() {
|
||||
r.Get("", pub.EnumeratePackageVersions)
|
||||
r.Get("/files/{version}", pub.DownloadPackageFile)
|
||||
r.Get("/{version}", pub.PackageVersionMetadata)
|
||||
})
|
||||
})
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
|
||||
r.Group("/pypi", func() {
|
||||
r.Post("/", reqPackageAccess(perm.AccessModeWrite), pypi.UploadPackageFile)
|
||||
r.Get("/files/{id}/{version}/{filename}", pypi.DownloadPackageFile)
|
||||
r.Get("/simple/{id}", pypi.PackageMetadata)
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
|
||||
r.Methods("HEAD,GET", "/rpm.repo", reqPackageAccess(perm.AccessModeRead), rpm.GetRepositoryConfig)
|
||||
r.PathGroup("/rpm/*", func(g *web.RouterPathGroup) {
|
||||
g.MatchPath("HEAD,GET", "/repository.key", rpm.GetRepositoryKey)
|
||||
g.MatchPath("HEAD,GET", "/<group:*>.repo", rpm.GetRepositoryConfig)
|
||||
g.MatchPath("HEAD", "/<group:*>/repodata/<filename>", rpm.CheckRepositoryFileExistence)
|
||||
g.MatchPath("GET", "/<group:*>/repodata/<filename>", rpm.GetRepositoryFile)
|
||||
g.MatchPath("PUT", "/<group:*>/upload", reqPackageAccess(perm.AccessModeWrite), rpm.UploadPackageFile)
|
||||
// this URL pattern is only used internally in the RPM index, it is generated by us, the filename part is not really used (can be anything)
|
||||
g.MatchPath("HEAD,GET", "/<group:*>/package/<name>/<version>/<architecture>", rpm.DownloadPackageFile)
|
||||
g.MatchPath("HEAD,GET", "/<group:*>/package/<name>/<version>/<architecture>/<filename>", rpm.DownloadPackageFile)
|
||||
g.MatchPath("DELETE", "/<group:*>/package/<name>/<version>/<architecture>", reqPackageAccess(perm.AccessModeWrite), rpm.DeletePackageFile)
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
|
||||
r.Group("/rubygems", func() {
|
||||
r.Get("/specs.4.8.gz", rubygems.EnumeratePackages)
|
||||
r.Get("/latest_specs.4.8.gz", rubygems.EnumeratePackagesLatest)
|
||||
r.Get("/prerelease_specs.4.8.gz", rubygems.EnumeratePackagesPreRelease)
|
||||
r.Get("/quick/Marshal.4.8/{filename}", rubygems.ServePackageSpecification)
|
||||
r.Get("/gems/{filename}", rubygems.DownloadPackageFile)
|
||||
r.Get("/info/{packagename}", rubygems.GetPackageInfo)
|
||||
r.Get("/versions", rubygems.GetAllPackagesVersions)
|
||||
r.Group("/api/v1/gems", func() {
|
||||
r.Post("/", rubygems.UploadPackageFile)
|
||||
r.Delete("/yank", rubygems.DeletePackage)
|
||||
}, reqPackageAccess(perm.AccessModeWrite))
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
|
||||
r.Group("/swift", func() {
|
||||
r.Group("", func() { // Needs to be unauthenticated.
|
||||
r.Post("", swift.CheckAuthenticate)
|
||||
r.Post("/login", swift.CheckAuthenticate)
|
||||
})
|
||||
r.Group("", func() {
|
||||
r.Group("/{scope}/{name}", func() {
|
||||
r.Group("", func() {
|
||||
r.Get("", swift.EnumeratePackageVersions)
|
||||
r.Get(".json", swift.EnumeratePackageVersions)
|
||||
}, swift.CheckAcceptMediaType(swift.AcceptJSON))
|
||||
r.PathGroup("/*", func(g *web.RouterPathGroup) {
|
||||
g.MatchPath("GET", "/<version>.json", swift.CheckAcceptMediaType(swift.AcceptJSON), swift.PackageVersionMetadata)
|
||||
g.MatchPath("GET", "/<version>.zip", swift.CheckAcceptMediaType(swift.AcceptZip), swift.DownloadPackageFile)
|
||||
g.MatchPath("GET", "/<version>/Package.swift", swift.CheckAcceptMediaType(swift.AcceptSwift), swift.DownloadManifest)
|
||||
g.MatchPath("GET", "/<version>", swift.CheckAcceptMediaType(swift.AcceptJSON), swift.PackageVersionMetadata)
|
||||
g.MatchPath("PUT", "/<version>", reqPackageAccess(perm.AccessModeWrite), swift.CheckAcceptMediaType(swift.AcceptJSON), swift.UploadPackageFile)
|
||||
})
|
||||
})
|
||||
r.Get("/identifiers", swift.CheckAcceptMediaType(swift.AcceptJSON), swift.LookupPackageIdentifiers)
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
})
|
||||
r.Group("/vagrant", func() {
|
||||
r.Group("/authenticate", func() {
|
||||
r.Get("", vagrant.CheckAuthenticate)
|
||||
})
|
||||
r.Group("/{name}", func() {
|
||||
r.Head("", vagrant.CheckBoxAvailable)
|
||||
r.Get("", vagrant.EnumeratePackageVersions)
|
||||
r.Group("/{version}/{provider}", func() {
|
||||
r.Get("", vagrant.DownloadPackageFile)
|
||||
r.Put("", reqPackageAccess(perm.AccessModeWrite), vagrant.UploadPackageFile)
|
||||
})
|
||||
})
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
}, context.UserAssignmentWeb(), context.PackageAssignment())
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// ContainerRoutes provides endpoints that implement the OCI API to serve containers
|
||||
// These have to be mounted on `/v2/...` to comply with the OCI spec:
|
||||
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md
|
||||
func ContainerRoutes() *web.Router {
|
||||
r := web.NewRouter()
|
||||
|
||||
r.Use(context.PackageContexter())
|
||||
|
||||
verifyAuth(r, []auth.Method{
|
||||
&auth.Basic{},
|
||||
&container.Auth{},
|
||||
})
|
||||
|
||||
// TODO: Content Discovery / References (not implemented yet)
|
||||
|
||||
r.Get("", container.ReqContainerAccess, container.DetermineSupport)
|
||||
r.Group("/token", func() {
|
||||
r.Get("", container.Authenticate)
|
||||
r.Post("", container.AuthenticateNotImplemented)
|
||||
})
|
||||
r.Get("/_catalog", container.ReqContainerAccess, container.GetRepositoryList)
|
||||
r.Group("/{username}", func() {
|
||||
r.PathGroup("/*", func(g *web.RouterPathGroup) {
|
||||
g.MatchPath("POST", "/<image:*>/blobs/uploads", reqPackageAccess(perm.AccessModeWrite), container.VerifyImageName, container.PostBlobsUploads)
|
||||
g.MatchPath("GET", "/<image:*>/tags/list", container.VerifyImageName, container.GetTagsList)
|
||||
|
||||
patternBlobsUploadsUUID := g.PatternRegexp(`/<image:*>/blobs/uploads/<uuid:[-.=\w]+>`, reqPackageAccess(perm.AccessModeWrite), container.VerifyImageName)
|
||||
g.MatchPattern("GET", patternBlobsUploadsUUID, container.GetBlobsUpload)
|
||||
g.MatchPattern("PATCH", patternBlobsUploadsUUID, container.PatchBlobsUpload)
|
||||
g.MatchPattern("PUT", patternBlobsUploadsUUID, container.PutBlobsUpload)
|
||||
g.MatchPattern("DELETE", patternBlobsUploadsUUID, container.DeleteBlobsUpload)
|
||||
|
||||
g.MatchPath("HEAD", `/<image:*>/blobs/<digest>`, container.VerifyImageName, container.HeadBlob)
|
||||
g.MatchPath("GET", `/<image:*>/blobs/<digest>`, container.VerifyImageName, container.GetBlob)
|
||||
g.MatchPath("DELETE", `/<image:*>/blobs/<digest>`, container.VerifyImageName, reqPackageAccess(perm.AccessModeWrite), container.DeleteBlob)
|
||||
|
||||
g.MatchPath("HEAD", `/<image:*>/manifests/<reference>`, container.VerifyImageName, container.HeadManifest)
|
||||
g.MatchPath("GET", `/<image:*>/manifests/<reference>`, container.VerifyImageName, container.GetManifest)
|
||||
g.MatchPath("PUT", `/<image:*>/manifests/<reference>`, container.VerifyImageName, reqPackageAccess(perm.AccessModeWrite), container.PutManifest)
|
||||
g.MatchPath("DELETE", `/<image:*>/manifests/<reference>`, container.VerifyImageName, reqPackageAccess(perm.AccessModeWrite), container.DeleteManifest)
|
||||
})
|
||||
}, container.ReqContainerAccess, context.UserAssignmentWeb(), context.PackageAssignment(), reqPackageAccess(perm.AccessModeRead))
|
||||
|
||||
return r
|
||||
}
|
||||
305
routers/api/packages/arch/arch.go
Normal file
305
routers/api/packages/arch/arch.go
Normal file
@@ -0,0 +1,305 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package arch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
packages_module "code.gitea.io/gitea/modules/packages"
|
||||
arch_module "code.gitea.io/gitea/modules/packages/arch"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/routers/api/packages/helper"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
arch_service "code.gitea.io/gitea/services/packages/arch"
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
ctx.PlainText(status, message)
|
||||
}
|
||||
|
||||
func GetRepositoryKey(ctx *context.Context) {
|
||||
_, pub, err := arch_service.GetOrCreateKeyPair(ctx, ctx.Package.Owner.ID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.ServeContent(strings.NewReader(pub), &context.ServeHeaderOptions{
|
||||
ContentType: "application/pgp-keys",
|
||||
})
|
||||
}
|
||||
|
||||
func UploadPackageFile(ctx *context.Context) {
|
||||
repository := strings.TrimSpace(ctx.PathParam("repository"))
|
||||
|
||||
upload, needToClose, err := ctx.UploadStream()
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if needToClose {
|
||||
defer upload.Close()
|
||||
}
|
||||
|
||||
buf, err := packages_module.CreateHashedBufferFromReader(upload)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer buf.Close()
|
||||
|
||||
pck, err := arch_module.ParsePackage(buf)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) || errors.Is(err, io.EOF) {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := buf.Seek(0, io.SeekStart); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
fileMetadataRaw, err := json.Marshal(pck.FileMetadata)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
signature, err := arch_service.SignData(ctx, ctx.Package.Owner.ID, buf)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := buf.Seek(0, io.SeekStart); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
release, err := arch_service.AquireRegistryLock(ctx, ctx.Package.Owner.ID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
|
||||
// Search for duplicates with different file compression
|
||||
has, err := packages_model.HasFiles(ctx, &packages_model.PackageFileSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
PackageType: packages_model.TypeArch,
|
||||
Query: fmt.Sprintf("%s-%s-%s.pkg.tar.%%", pck.Name, pck.Version, pck.FileMetadata.Architecture),
|
||||
Properties: map[string]string{
|
||||
arch_module.PropertyRepository: repository,
|
||||
arch_module.PropertyArchitecture: pck.FileMetadata.Architecture,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if has {
|
||||
apiError(ctx, http.StatusConflict, packages_model.ErrDuplicatePackageFile)
|
||||
return
|
||||
}
|
||||
|
||||
_, _, err = packages_service.CreatePackageOrAddFileToExisting(
|
||||
ctx,
|
||||
&packages_service.PackageCreationInfo{
|
||||
PackageInfo: packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeArch,
|
||||
Name: pck.Name,
|
||||
Version: pck.Version,
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Metadata: pck.VersionMetadata,
|
||||
},
|
||||
&packages_service.PackageFileCreationInfo{
|
||||
PackageFileInfo: packages_service.PackageFileInfo{
|
||||
Filename: fmt.Sprintf("%s-%s-%s.pkg.tar.%s", pck.Name, pck.Version, pck.FileMetadata.Architecture, pck.FileCompressionExtension),
|
||||
CompositeKey: fmt.Sprintf("%s|%s", repository, pck.FileMetadata.Architecture),
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Data: buf,
|
||||
IsLead: true,
|
||||
Properties: map[string]string{
|
||||
arch_module.PropertyRepository: repository,
|
||||
arch_module.PropertyArchitecture: pck.FileMetadata.Architecture,
|
||||
arch_module.PropertyMetadata: string(fileMetadataRaw),
|
||||
arch_module.PropertySignature: base64.StdEncoding.EncodeToString(signature),
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case packages_model.ErrDuplicatePackageVersion, packages_model.ErrDuplicatePackageFile:
|
||||
apiError(ctx, http.StatusConflict, err)
|
||||
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
default:
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := arch_service.BuildSpecificRepositoryFiles(ctx, ctx.Package.Owner.ID, repository, pck.FileMetadata.Architecture); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusCreated)
|
||||
}
|
||||
|
||||
func GetPackageOrRepositoryFile(ctx *context.Context) {
|
||||
repository := ctx.PathParam("repository")
|
||||
architecture := ctx.PathParam("architecture")
|
||||
filename := ctx.PathParam("filename")
|
||||
filenameOrig := filename
|
||||
|
||||
isSignature := strings.HasSuffix(filename, ".sig")
|
||||
if isSignature {
|
||||
filename = filename[:len(filename)-len(".sig")]
|
||||
}
|
||||
|
||||
opts := &packages_model.PackageFileSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
PackageType: packages_model.TypeArch,
|
||||
Query: filename,
|
||||
CompositeKey: fmt.Sprintf("%s|%s", repository, architecture),
|
||||
}
|
||||
|
||||
if strings.HasSuffix(filename, ".db.tar.gz") || strings.HasSuffix(filename, ".files.tar.gz") || strings.HasSuffix(filename, ".files") || strings.HasSuffix(filename, ".db") {
|
||||
// The requested filename is based on the user-defined repository name.
|
||||
// Normalize everything to "packages.db".
|
||||
opts.Query = arch_service.IndexArchiveFilename
|
||||
|
||||
pv, err := arch_service.GetOrCreateRepositoryVersion(ctx, ctx.Package.Owner.ID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
opts.VersionID = pv.ID
|
||||
}
|
||||
|
||||
pfs, _, err := packages_model.SearchFiles(ctx, opts)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pfs) == 0 {
|
||||
// Try again with architecture 'any'
|
||||
if architecture == arch_module.AnyArch {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
opts.CompositeKey = fmt.Sprintf("%s|%s", repository, arch_module.AnyArch)
|
||||
if pfs, _, err = packages_model.SearchFiles(ctx, opts); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(pfs) != 1 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
if isSignature {
|
||||
pfps, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypeFile, pfs[0].ID, arch_module.PropertySignature)
|
||||
if err != nil || len(pfps) == 0 {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := base64.StdEncoding.DecodeString(pfps[0].Value)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.ServeContent(bytes.NewReader(data), &context.ServeHeaderOptions{
|
||||
Filename: filenameOrig,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownload(ctx, pfs[0], ctx.Req.Method)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
|
||||
func DeletePackageVersion(ctx *context.Context) {
|
||||
repository := ctx.PathParam("repository")
|
||||
architecture := ctx.PathParam("architecture")
|
||||
name := ctx.PathParam("name")
|
||||
version := ctx.PathParam("version")
|
||||
|
||||
release, err := arch_service.AquireRegistryLock(ctx, ctx.Package.Owner.ID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeArch, name, version)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{
|
||||
VersionID: pv.ID,
|
||||
CompositeKey: fmt.Sprintf("%s|%s", repository, architecture),
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pfs) != 1 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := packages_service.RemovePackageFileAndVersionIfUnreferenced(ctx, ctx.Doer, pfs[0]); err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := arch_service.BuildSpecificRepositoryFiles(ctx, ctx.Package.Owner.ID, repository, architecture); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
308
routers/api/packages/cargo/cargo.go
Normal file
308
routers/api/packages/cargo/cargo.go
Normal file
@@ -0,0 +1,308 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cargo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
packages_module "code.gitea.io/gitea/modules/packages"
|
||||
cargo_module "code.gitea.io/gitea/modules/packages/cargo"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/routers/api/packages/helper"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/convert"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
cargo_service "code.gitea.io/gitea/services/packages/cargo"
|
||||
)
|
||||
|
||||
// https://doc.rust-lang.org/cargo/reference/registries.html#web-api
|
||||
type StatusResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Errors []StatusMessage `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
type StatusMessage struct {
|
||||
Message string `json:"detail"`
|
||||
}
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
ctx.JSON(status, StatusResponse{
|
||||
OK: false,
|
||||
Errors: []StatusMessage{
|
||||
{
|
||||
Message: message,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// https://rust-lang.github.io/rfcs/2789-sparse-index.html
|
||||
func RepositoryConfig(ctx *context.Context) {
|
||||
ctx.JSON(http.StatusOK, cargo_service.BuildConfig(ctx.Package.Owner, setting.Service.RequireSignInViewStrict || ctx.Package.Owner.Visibility != structs.VisibleTypePublic))
|
||||
}
|
||||
|
||||
func EnumeratePackageVersions(ctx *context.Context) {
|
||||
p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeCargo, ctx.PathParam("package"))
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
b, err := cargo_service.BuildPackageIndex(ctx, p)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if b == nil {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.PlainTextBytes(http.StatusOK, b.Bytes())
|
||||
}
|
||||
|
||||
type SearchResult struct {
|
||||
Crates []*SearchResultCrate `json:"crates"`
|
||||
Meta SearchResultMeta `json:"meta"`
|
||||
}
|
||||
|
||||
type SearchResultCrate struct {
|
||||
Name string `json:"name"`
|
||||
LatestVersion string `json:"max_version"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type SearchResultMeta struct {
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
// https://doc.rust-lang.org/cargo/reference/registries.html#search
|
||||
func SearchPackages(ctx *context.Context) {
|
||||
page := max(ctx.FormInt("page"), 1)
|
||||
perPage := ctx.FormInt("per_page")
|
||||
paginator := db.ListOptions{
|
||||
Page: page,
|
||||
PageSize: convert.ToCorrectPageSize(perPage),
|
||||
}
|
||||
|
||||
pvs, total, err := packages_model.SearchLatestVersions(
|
||||
ctx,
|
||||
&packages_model.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Type: packages_model.TypeCargo,
|
||||
Name: packages_model.SearchValue{Value: ctx.FormTrim("q")},
|
||||
IsInternal: optional.Some(false),
|
||||
Paginator: &paginator,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
crates := make([]*SearchResultCrate, 0, len(pvs))
|
||||
for _, pd := range pds {
|
||||
crates = append(crates, &SearchResultCrate{
|
||||
Name: pd.Package.Name,
|
||||
LatestVersion: pd.Version.Version,
|
||||
Description: pd.Metadata.(*cargo_module.Metadata).Description,
|
||||
})
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, SearchResult{
|
||||
Crates: crates,
|
||||
Meta: SearchResultMeta{
|
||||
Total: total,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type Owners struct {
|
||||
Users []OwnerUser `json:"users"`
|
||||
}
|
||||
|
||||
type OwnerUser struct {
|
||||
ID int64 `json:"id"`
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// https://doc.rust-lang.org/cargo/reference/registries.html#owners-list
|
||||
func ListOwners(ctx *context.Context) {
|
||||
ctx.JSON(http.StatusOK, Owners{
|
||||
Users: []OwnerUser{
|
||||
{
|
||||
ID: ctx.Package.Owner.ID,
|
||||
Login: ctx.Package.Owner.Name,
|
||||
Name: ctx.Package.Owner.DisplayName(),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// DownloadPackageFile serves the content of a package
|
||||
func DownloadPackageFile(ctx *context.Context) {
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion(
|
||||
ctx,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeCargo,
|
||||
Name: ctx.PathParam("package"),
|
||||
Version: ctx.PathParam("version"),
|
||||
},
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: strings.ToLower(fmt.Sprintf("%s-%s.crate", ctx.PathParam("package"), ctx.PathParam("version"))),
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(err, packages_model.ErrPackageFileNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
|
||||
// https://doc.rust-lang.org/cargo/reference/registries.html#publish
|
||||
func UploadPackage(ctx *context.Context) {
|
||||
defer ctx.Req.Body.Close()
|
||||
|
||||
cp, err := cargo_module.ParsePackage(ctx.Req.Body)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
buf, err := packages_module.CreateHashedBufferFromReader(cp.Content)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer buf.Close()
|
||||
|
||||
if buf.Size() != cp.ContentSize {
|
||||
apiError(ctx, http.StatusBadRequest, "invalid content size")
|
||||
return
|
||||
}
|
||||
|
||||
pv, _, err := packages_service.CreatePackageAndAddFile(
|
||||
ctx,
|
||||
&packages_service.PackageCreationInfo{
|
||||
PackageInfo: packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeCargo,
|
||||
Name: cp.Name,
|
||||
Version: cp.Version,
|
||||
},
|
||||
SemverCompatible: true,
|
||||
Creator: ctx.Doer,
|
||||
Metadata: cp.Metadata,
|
||||
VersionProperties: map[string]string{
|
||||
cargo_module.PropertyYanked: strconv.FormatBool(false),
|
||||
},
|
||||
},
|
||||
&packages_service.PackageFileCreationInfo{
|
||||
PackageFileInfo: packages_service.PackageFileInfo{
|
||||
Filename: strings.ToLower(fmt.Sprintf("%s-%s.crate", cp.Name, cp.Version)),
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Data: buf,
|
||||
IsLead: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case packages_model.ErrDuplicatePackageVersion:
|
||||
apiError(ctx, http.StatusConflict, err)
|
||||
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
default:
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := cargo_service.UpdatePackageIndexIfExists(ctx, ctx.Doer, ctx.Package.Owner, pv.PackageID); err != nil {
|
||||
if err := packages_service.DeletePackageVersionAndReferences(ctx, pv); err != nil {
|
||||
log.Error("Rollback creation of package version: %v", err)
|
||||
}
|
||||
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, StatusResponse{OK: true})
|
||||
}
|
||||
|
||||
// https://doc.rust-lang.org/cargo/reference/registries.html#yank
|
||||
func YankPackage(ctx *context.Context) {
|
||||
yankPackage(ctx, true)
|
||||
}
|
||||
|
||||
// https://doc.rust-lang.org/cargo/reference/registries.html#unyank
|
||||
func UnyankPackage(ctx *context.Context) {
|
||||
yankPackage(ctx, false)
|
||||
}
|
||||
|
||||
func yankPackage(ctx *context.Context, yank bool) {
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeCargo, ctx.PathParam("package"), ctx.PathParam("version"))
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
pps, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypeVersion, pv.ID, cargo_module.PropertyYanked)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pps) == 0 {
|
||||
apiError(ctx, http.StatusInternalServerError, "Property not found")
|
||||
return
|
||||
}
|
||||
|
||||
pp := pps[0]
|
||||
pp.Value = strconv.FormatBool(yank)
|
||||
|
||||
if err := packages_model.UpdateProperty(ctx, pp); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := cargo_service.UpdatePackageIndexIfExists(ctx, ctx.Doer, ctx.Package.Owner, pv.PackageID); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, StatusResponse{OK: true})
|
||||
}
|
||||
275
routers/api/packages/chef/auth.go
Normal file
275
routers/api/packages/chef/auth.go
Normal file
@@ -0,0 +1,275 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package chef
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
chef_module "code.gitea.io/gitea/modules/packages/chef"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/services/auth"
|
||||
)
|
||||
|
||||
const (
|
||||
maxTimeDifference = 10 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
algorithmPattern = regexp.MustCompile(`algorithm=(\w+)`)
|
||||
versionPattern = regexp.MustCompile(`version=(\d+\.\d+)`)
|
||||
authorizationPattern = regexp.MustCompile(`\AX-Ops-Authorization-(\d+)`)
|
||||
|
||||
_ auth.Method = &Auth{}
|
||||
)
|
||||
|
||||
// Documentation:
|
||||
// https://docs.chef.io/server/api_chef_server/#required-headers
|
||||
// https://github.com/chef-boneyard/chef-rfc/blob/master/rfc065-sign-v1.3.md
|
||||
// https://github.com/chef/mixlib-authentication/blob/bc8adbef833d4be23dc78cb23e6fe44b51ebc34f/lib/mixlib/authentication/signedheaderauth.rb
|
||||
|
||||
type Auth struct{}
|
||||
|
||||
func (a *Auth) Name() string {
|
||||
return "chef"
|
||||
}
|
||||
|
||||
// Verify extracts the user from the signed request
|
||||
// If the request is signed with the user private key the user is verified.
|
||||
func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataStore, sess auth.SessionStore) (*user_model.User, error) {
|
||||
u, err := getUserFromRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
pub, err := getUserPublicKey(req.Context(), u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := verifyTimestamp(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
version, err := getSignVersion(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := verifySignedHeaders(req, version, pub.(*rsa.PublicKey)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func getUserFromRequest(req *http.Request) (*user_model.User, error) {
|
||||
username := req.Header.Get("X-Ops-Userid")
|
||||
if username == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return user_model.GetUserByName(req.Context(), username)
|
||||
}
|
||||
|
||||
func getUserPublicKey(ctx context.Context, u *user_model.User) (crypto.PublicKey, error) {
|
||||
pubKey, err := user_model.GetSetting(ctx, u.ID, chef_module.SettingPublicPem)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pubPem, _ := pem.Decode([]byte(pubKey))
|
||||
|
||||
return x509.ParsePKIXPublicKey(pubPem.Bytes)
|
||||
}
|
||||
|
||||
func verifyTimestamp(req *http.Request) error {
|
||||
hdr := req.Header.Get("X-Ops-Timestamp")
|
||||
if hdr == "" {
|
||||
return util.NewInvalidArgumentErrorf("X-Ops-Timestamp header missing")
|
||||
}
|
||||
|
||||
ts, err := time.Parse(time.RFC3339, hdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
diff := time.Now().UTC().Sub(ts)
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
|
||||
if diff > maxTimeDifference {
|
||||
return errors.New("time difference")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getSignVersion(req *http.Request) (string, error) {
|
||||
hdr := req.Header.Get("X-Ops-Sign")
|
||||
if hdr == "" {
|
||||
return "", util.NewInvalidArgumentErrorf("X-Ops-Sign header missing")
|
||||
}
|
||||
|
||||
m := versionPattern.FindStringSubmatch(hdr)
|
||||
if len(m) != 2 {
|
||||
return "", util.NewInvalidArgumentErrorf("invalid X-Ops-Sign header")
|
||||
}
|
||||
|
||||
switch m[1] {
|
||||
case "1.0", "1.1", "1.2", "1.3":
|
||||
default:
|
||||
return "", util.NewInvalidArgumentErrorf("unsupported version")
|
||||
}
|
||||
|
||||
version := m[1]
|
||||
|
||||
m = algorithmPattern.FindStringSubmatch(hdr)
|
||||
if len(m) == 2 && m[1] != "sha1" && !(m[1] == "sha256" && version == "1.3") {
|
||||
return "", util.NewInvalidArgumentErrorf("unsupported algorithm")
|
||||
}
|
||||
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func verifySignedHeaders(req *http.Request, version string, pub *rsa.PublicKey) error {
|
||||
authorizationData, err := getAuthorizationData(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkData := buildCheckData(req, version)
|
||||
|
||||
switch version {
|
||||
case "1.3":
|
||||
return verifyDataNew(authorizationData, checkData, pub, crypto.SHA256)
|
||||
case "1.2":
|
||||
return verifyDataNew(authorizationData, checkData, pub, crypto.SHA1)
|
||||
default:
|
||||
return verifyDataOld(authorizationData, checkData, pub)
|
||||
}
|
||||
}
|
||||
|
||||
func getAuthorizationData(req *http.Request) ([]byte, error) {
|
||||
valueList := make(map[int]string)
|
||||
for k, vs := range req.Header {
|
||||
if m := authorizationPattern.FindStringSubmatch(k); m != nil {
|
||||
index, _ := strconv.Atoi(m[1])
|
||||
var v string
|
||||
if len(vs) == 0 {
|
||||
v = ""
|
||||
} else {
|
||||
v = vs[0]
|
||||
}
|
||||
valueList[index] = v
|
||||
}
|
||||
}
|
||||
|
||||
tmp := make([]string, len(valueList))
|
||||
for k, v := range valueList {
|
||||
if k > len(tmp) {
|
||||
return nil, errors.New("invalid X-Ops-Authorization headers")
|
||||
}
|
||||
tmp[k-1] = v
|
||||
}
|
||||
|
||||
return base64.StdEncoding.DecodeString(strings.Join(tmp, ""))
|
||||
}
|
||||
|
||||
func buildCheckData(req *http.Request, version string) []byte {
|
||||
username := req.Header.Get("X-Ops-Userid")
|
||||
if version != "1.0" && version != "1.3" {
|
||||
sum := sha1.Sum([]byte(username))
|
||||
username = base64.StdEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
var data string
|
||||
if version == "1.3" {
|
||||
data = fmt.Sprintf(
|
||||
"Method:%s\nPath:%s\nX-Ops-Content-Hash:%s\nX-Ops-Sign:version=%s\nX-Ops-Timestamp:%s\nX-Ops-UserId:%s\nX-Ops-Server-API-Version:%s",
|
||||
req.Method,
|
||||
path.Clean(req.URL.Path),
|
||||
req.Header.Get("X-Ops-Content-Hash"),
|
||||
version,
|
||||
req.Header.Get("X-Ops-Timestamp"),
|
||||
username,
|
||||
req.Header.Get("X-Ops-Server-Api-Version"),
|
||||
)
|
||||
} else {
|
||||
sum := sha1.Sum([]byte(path.Clean(req.URL.Path)))
|
||||
data = fmt.Sprintf(
|
||||
"Method:%s\nHashed Path:%s\nX-Ops-Content-Hash:%s\nX-Ops-Timestamp:%s\nX-Ops-UserId:%s",
|
||||
req.Method,
|
||||
base64.StdEncoding.EncodeToString(sum[:]),
|
||||
req.Header.Get("X-Ops-Content-Hash"),
|
||||
req.Header.Get("X-Ops-Timestamp"),
|
||||
username,
|
||||
)
|
||||
}
|
||||
|
||||
return []byte(data)
|
||||
}
|
||||
|
||||
func verifyDataNew(signature, data []byte, pub *rsa.PublicKey, algo crypto.Hash) error {
|
||||
var h hash.Hash
|
||||
if algo == crypto.SHA256 {
|
||||
h = sha256.New()
|
||||
} else {
|
||||
h = sha1.New()
|
||||
}
|
||||
if _, err := h.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return rsa.VerifyPKCS1v15(pub, algo, h.Sum(nil), signature)
|
||||
}
|
||||
|
||||
func verifyDataOld(signature, data []byte, pub *rsa.PublicKey) error {
|
||||
c := new(big.Int)
|
||||
m := new(big.Int)
|
||||
m.SetBytes(signature)
|
||||
e := big.NewInt(int64(pub.E))
|
||||
c.Exp(m, e, pub.N)
|
||||
|
||||
out := c.Bytes()
|
||||
|
||||
skip := 0
|
||||
for i := 2; i < len(out); i++ {
|
||||
if i+1 >= len(out) {
|
||||
break
|
||||
}
|
||||
if out[i] == 0xFF && out[i+1] == 0 {
|
||||
skip = i + 2
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Equal(out[skip:], data) {
|
||||
return errors.New("could not verify signature")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
402
routers/api/packages/chef/chef.go
Normal file
402
routers/api/packages/chef/chef.go
Normal file
@@ -0,0 +1,402 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package chef
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
packages_module "code.gitea.io/gitea/modules/packages"
|
||||
chef_module "code.gitea.io/gitea/modules/packages/chef"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/routers/api/packages/helper"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
type Error struct {
|
||||
ErrorMessages []string `json:"error_messages"`
|
||||
}
|
||||
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
ctx.JSON(status, Error{
|
||||
ErrorMessages: []string{message},
|
||||
})
|
||||
}
|
||||
|
||||
func PackagesUniverse(ctx *context.Context) {
|
||||
pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Type: packages_model.TypeChef,
|
||||
IsInternal: optional.Some(false),
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
type VersionInfo struct {
|
||||
LocationType string `json:"location_type"`
|
||||
LocationPath string `json:"location_path"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
Dependencies map[string]string `json:"dependencies"`
|
||||
}
|
||||
|
||||
baseURL := setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/chef/api/v1"
|
||||
|
||||
universe := make(map[string]map[string]*VersionInfo)
|
||||
for _, pd := range pds {
|
||||
if _, ok := universe[pd.Package.Name]; !ok {
|
||||
universe[pd.Package.Name] = make(map[string]*VersionInfo)
|
||||
}
|
||||
universe[pd.Package.Name][pd.Version.Version] = &VersionInfo{
|
||||
LocationType: "opscode",
|
||||
LocationPath: baseURL,
|
||||
DownloadURL: fmt.Sprintf("%s/cookbooks/%s/versions/%s/download", baseURL, url.PathEscape(pd.Package.Name), pd.Version.Version),
|
||||
Dependencies: pd.Metadata.(*chef_module.Metadata).Dependencies,
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, universe)
|
||||
}
|
||||
|
||||
// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_list.rb
|
||||
// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_search.rb
|
||||
func EnumeratePackages(ctx *context.Context) {
|
||||
opts := &packages_model.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Type: packages_model.TypeChef,
|
||||
Name: packages_model.SearchValue{Value: ctx.FormTrim("q")},
|
||||
IsInternal: optional.Some(false),
|
||||
Paginator: db.NewAbsoluteListOptions(
|
||||
ctx.FormInt("start"),
|
||||
ctx.FormInt("items"),
|
||||
),
|
||||
}
|
||||
|
||||
switch strings.ToLower(ctx.FormTrim("order")) {
|
||||
case "recently_updated", "recently_added":
|
||||
opts.Sort = packages_model.SortCreatedDesc
|
||||
default:
|
||||
opts.Sort = packages_model.SortNameAsc
|
||||
}
|
||||
|
||||
pvs, total, err := packages_model.SearchLatestVersions(ctx, opts)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
type Item struct {
|
||||
CookbookName string `json:"cookbook_name"`
|
||||
CookbookMaintainer string `json:"cookbook_maintainer"`
|
||||
CookbookDescription string `json:"cookbook_description"`
|
||||
Cookbook string `json:"cookbook"`
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Start int `json:"start"`
|
||||
Total int `json:"total"`
|
||||
Items []*Item `json:"items"`
|
||||
}
|
||||
|
||||
baseURL := setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/chef/api/v1/cookbooks/"
|
||||
|
||||
items := make([]*Item, 0, len(pds))
|
||||
for _, pd := range pds {
|
||||
metadata := pd.Metadata.(*chef_module.Metadata)
|
||||
|
||||
items = append(items, &Item{
|
||||
CookbookName: pd.Package.Name,
|
||||
CookbookMaintainer: metadata.Author,
|
||||
CookbookDescription: metadata.Description,
|
||||
Cookbook: baseURL + url.PathEscape(pd.Package.Name),
|
||||
})
|
||||
}
|
||||
|
||||
skip, _ := opts.Paginator.GetSkipTake()
|
||||
|
||||
ctx.JSON(http.StatusOK, &Result{
|
||||
Start: skip,
|
||||
Total: int(total),
|
||||
Items: items,
|
||||
})
|
||||
}
|
||||
|
||||
// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_show.rb
|
||||
func PackageMetadata(ctx *context.Context) {
|
||||
packageName := ctx.PathParam("name")
|
||||
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeChef, packageName)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pvs) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
sort.Slice(pds, func(i, j int) bool {
|
||||
return pds[i].SemVer.LessThan(pds[j].SemVer)
|
||||
})
|
||||
|
||||
type Result struct {
|
||||
Name string `json:"name"`
|
||||
Maintainer string `json:"maintainer"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
LatestVersion string `json:"latest_version"`
|
||||
SourceURL string `json:"source_url"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Deprecated bool `json:"deprecated"`
|
||||
Versions []string `json:"versions"`
|
||||
}
|
||||
|
||||
baseURL := fmt.Sprintf("%sapi/packages/%s/chef/api/v1/cookbooks/%s/versions/", setting.AppURL, ctx.Package.Owner.Name, url.PathEscape(packageName))
|
||||
|
||||
versions := make([]string, 0, len(pds))
|
||||
for _, pd := range pds {
|
||||
versions = append(versions, baseURL+pd.Version.Version)
|
||||
}
|
||||
|
||||
latest := pds[len(pds)-1]
|
||||
|
||||
metadata := latest.Metadata.(*chef_module.Metadata)
|
||||
|
||||
ctx.JSON(http.StatusOK, &Result{
|
||||
Name: latest.Package.Name,
|
||||
Maintainer: metadata.Author,
|
||||
Description: metadata.Description,
|
||||
LatestVersion: baseURL + latest.Version.Version,
|
||||
SourceURL: metadata.RepositoryURL,
|
||||
CreatedAt: latest.Version.CreatedUnix.AsLocalTime(),
|
||||
UpdatedAt: latest.Version.CreatedUnix.AsLocalTime(),
|
||||
Deprecated: false,
|
||||
Versions: versions,
|
||||
})
|
||||
}
|
||||
|
||||
// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_show.rb
|
||||
func PackageVersionMetadata(ctx *context.Context) {
|
||||
packageName := ctx.PathParam("name")
|
||||
packageVersion := strings.ReplaceAll(ctx.PathParam("version"), "_", ".") // Chef calls this endpoint with "_" instead of "."?!
|
||||
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeChef, packageName, packageVersion)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
pd, err := packages_model.GetPackageDescriptor(ctx, pv)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Version string `json:"version"`
|
||||
TarballFileSize int64 `json:"tarball_file_size"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
Cookbook string `json:"cookbook"`
|
||||
File string `json:"file"`
|
||||
License string `json:"license"`
|
||||
Dependencies map[string]string `json:"dependencies"`
|
||||
}
|
||||
|
||||
baseURL := fmt.Sprintf("%sapi/packages/%s/chef/api/v1/cookbooks/%s", setting.AppURL, ctx.Package.Owner.Name, url.PathEscape(pd.Package.Name))
|
||||
|
||||
metadata := pd.Metadata.(*chef_module.Metadata)
|
||||
|
||||
ctx.JSON(http.StatusOK, &Result{
|
||||
Version: pd.Version.Version,
|
||||
TarballFileSize: pd.Files[0].Blob.Size,
|
||||
PublishedAt: pd.Version.CreatedUnix.AsLocalTime(),
|
||||
Cookbook: baseURL,
|
||||
File: fmt.Sprintf("%s/versions/%s/download", baseURL, pd.Version.Version),
|
||||
License: metadata.License,
|
||||
Dependencies: metadata.Dependencies,
|
||||
})
|
||||
}
|
||||
|
||||
// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_share.rb
|
||||
func UploadPackage(ctx *context.Context) {
|
||||
file, _, err := ctx.Req.FormFile("tarball")
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
buf, err := packages_module.CreateHashedBufferFromReader(file)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer buf.Close()
|
||||
|
||||
pck, err := chef_module.ParsePackage(buf)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := buf.Seek(0, io.SeekStart); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
_, _, err = packages_service.CreatePackageAndAddFile(
|
||||
ctx,
|
||||
&packages_service.PackageCreationInfo{
|
||||
PackageInfo: packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeChef,
|
||||
Name: pck.Name,
|
||||
Version: pck.Version,
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
SemverCompatible: true,
|
||||
Metadata: pck.Metadata,
|
||||
},
|
||||
&packages_service.PackageFileCreationInfo{
|
||||
PackageFileInfo: packages_service.PackageFileInfo{
|
||||
Filename: strings.ToLower(pck.Version + ".tar.gz"),
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Data: buf,
|
||||
IsLead: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case packages_model.ErrDuplicatePackageVersion:
|
||||
apiError(ctx, http.StatusConflict, err)
|
||||
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
default:
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusCreated, make(map[any]any))
|
||||
}
|
||||
|
||||
// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_download.rb
|
||||
func DownloadPackage(ctx *context.Context) {
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeChef, ctx.PathParam("name"), ctx.PathParam("version"))
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
pd, err := packages_model.GetPackageDescriptor(ctx, pv)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
pf := pd.Files[0].File
|
||||
|
||||
s, u, _, err := packages_service.OpenFileForDownload(ctx, pf, ctx.Req.Method)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
|
||||
// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_unshare.rb
|
||||
func DeletePackageVersion(ctx *context.Context) {
|
||||
packageName := ctx.PathParam("name")
|
||||
packageVersion := ctx.PathParam("version")
|
||||
|
||||
err := packages_service.RemovePackageVersionByNameAndVersion(
|
||||
ctx,
|
||||
ctx.Doer,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeChef,
|
||||
Name: packageName,
|
||||
Version: packageVersion,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_unshare.rb
|
||||
func DeletePackage(ctx *context.Context) {
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeChef, ctx.PathParam("name"))
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(pvs) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, pv := range pvs {
|
||||
if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusOK)
|
||||
}
|
||||
135
routers/api/packages/composer/api.go
Normal file
135
routers/api/packages/composer/api.go
Normal file
@@ -0,0 +1,135 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package composer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
composer_module "code.gitea.io/gitea/modules/packages/composer"
|
||||
)
|
||||
|
||||
// ServiceIndexResponse contains registry endpoints
|
||||
type ServiceIndexResponse struct {
|
||||
SearchTemplate string `json:"search"`
|
||||
MetadataTemplate string `json:"metadata-url"`
|
||||
PackageList string `json:"list"`
|
||||
}
|
||||
|
||||
func createServiceIndexResponse(registryURL string) *ServiceIndexResponse {
|
||||
return &ServiceIndexResponse{
|
||||
SearchTemplate: registryURL + "/search.json?q=%query%&type=%type%",
|
||||
MetadataTemplate: registryURL + "/p2/%package%.json",
|
||||
PackageList: registryURL + "/list.json",
|
||||
}
|
||||
}
|
||||
|
||||
// SearchResultResponse contains search results
|
||||
type SearchResultResponse struct {
|
||||
Total int64 `json:"total"`
|
||||
Results []*SearchResult `json:"results"`
|
||||
NextLink string `json:"next,omitempty"`
|
||||
}
|
||||
|
||||
// SearchResult contains a search result
|
||||
type SearchResult struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Downloads int64 `json:"downloads"`
|
||||
}
|
||||
|
||||
func createSearchResultResponse(total int64, pds []*packages_model.PackageDescriptor, nextLink string) *SearchResultResponse {
|
||||
results := make([]*SearchResult, 0, len(pds))
|
||||
|
||||
for _, pd := range pds {
|
||||
results = append(results, &SearchResult{
|
||||
Name: pd.Package.Name,
|
||||
Description: pd.Metadata.(*composer_module.Metadata).Description,
|
||||
Downloads: pd.Version.DownloadCount,
|
||||
})
|
||||
}
|
||||
|
||||
return &SearchResultResponse{
|
||||
Total: total,
|
||||
Results: results,
|
||||
NextLink: nextLink,
|
||||
}
|
||||
}
|
||||
|
||||
// PackageMetadataResponse contains packages metadata
|
||||
type PackageMetadataResponse struct {
|
||||
Minified string `json:"minified"`
|
||||
Packages map[string][]*PackageVersionMetadata `json:"packages"`
|
||||
}
|
||||
|
||||
// PackageVersionMetadata contains package metadata
|
||||
// https://getcomposer.org/doc/05-repositories.md#package
|
||||
type PackageVersionMetadata struct {
|
||||
*composer_module.Metadata
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Type string `json:"type"`
|
||||
Created time.Time `json:"time"`
|
||||
Dist Dist `json:"dist"`
|
||||
Source Source `json:"source"`
|
||||
}
|
||||
|
||||
// Dist contains package download information
|
||||
type Dist struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
Checksum string `json:"shasum"`
|
||||
}
|
||||
|
||||
// Source contains package source information
|
||||
type Source struct {
|
||||
URL string `json:"url"`
|
||||
Type string `json:"type"`
|
||||
Reference string `json:"reference"`
|
||||
}
|
||||
|
||||
func createPackageMetadataResponse(registryURL string, pds []*packages_model.PackageDescriptor) *PackageMetadataResponse {
|
||||
versions := make([]*PackageVersionMetadata, 0, len(pds))
|
||||
|
||||
for _, pd := range pds {
|
||||
packageType := ""
|
||||
for _, pvp := range pd.VersionProperties {
|
||||
if pvp.Name == composer_module.TypeProperty {
|
||||
packageType = pvp.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
pkg := PackageVersionMetadata{
|
||||
Name: pd.Package.Name,
|
||||
Version: pd.Version.Version,
|
||||
Type: packageType,
|
||||
Created: pd.Version.CreatedUnix.AsLocalTime(),
|
||||
Metadata: pd.Metadata.(*composer_module.Metadata),
|
||||
Dist: Dist{
|
||||
Type: "zip",
|
||||
URL: fmt.Sprintf("%s/files/%s/%s/%s", registryURL, url.PathEscape(pd.Package.LowerName), url.PathEscape(pd.Version.LowerVersion), url.PathEscape(pd.Files[0].File.LowerName)),
|
||||
Checksum: pd.Files[0].Blob.HashSHA1,
|
||||
},
|
||||
}
|
||||
if pd.Repository != nil {
|
||||
pkg.Source = Source{
|
||||
URL: pd.Repository.HTMLURL(),
|
||||
Type: "git",
|
||||
Reference: pd.Version.Version,
|
||||
}
|
||||
}
|
||||
|
||||
versions = append(versions, &pkg)
|
||||
}
|
||||
|
||||
return &PackageMetadataResponse{
|
||||
Minified: "composer/2.0",
|
||||
Packages: map[string][]*PackageVersionMetadata{
|
||||
pds[0].Package.Name: versions,
|
||||
},
|
||||
}
|
||||
}
|
||||
258
routers/api/packages/composer/composer.go
Normal file
258
routers/api/packages/composer/composer.go
Normal file
@@ -0,0 +1,258 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package composer
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
packages_module "code.gitea.io/gitea/modules/packages"
|
||||
composer_module "code.gitea.io/gitea/modules/packages/composer"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/routers/api/packages/helper"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/convert"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
|
||||
"github.com/hashicorp/go-version"
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
type Error struct {
|
||||
Status int `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
ctx.JSON(status, struct {
|
||||
Errors []Error `json:"errors"`
|
||||
}{
|
||||
Errors: []Error{
|
||||
{Status: status, Message: message},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ServiceIndex displays registry endpoints
|
||||
func ServiceIndex(ctx *context.Context) {
|
||||
resp := createServiceIndexResponse(setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/composer")
|
||||
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// SearchPackages searches packages, only "q" is supported
|
||||
// https://packagist.org/apidoc#search-packages
|
||||
func SearchPackages(ctx *context.Context) {
|
||||
page := max(ctx.FormInt("page"), 1)
|
||||
perPage := ctx.FormInt("per_page")
|
||||
paginator := db.ListOptions{
|
||||
Page: page,
|
||||
PageSize: convert.ToCorrectPageSize(perPage),
|
||||
}
|
||||
|
||||
opts := &packages_model.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Type: packages_model.TypeComposer,
|
||||
Name: packages_model.SearchValue{Value: ctx.FormTrim("q")},
|
||||
IsInternal: optional.Some(false),
|
||||
Paginator: &paginator,
|
||||
}
|
||||
if ctx.FormTrim("type") != "" {
|
||||
opts.Properties = map[string]string{
|
||||
composer_module.TypeProperty: ctx.FormTrim("type"),
|
||||
}
|
||||
}
|
||||
|
||||
pvs, total, err := packages_model.SearchLatestVersions(ctx, opts)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
nextLink := ""
|
||||
if len(pvs) == paginator.PageSize {
|
||||
u, err := url.Parse(setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/composer/search.json")
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("q", ctx.FormTrim("q"))
|
||||
q.Set("type", ctx.FormTrim("type"))
|
||||
q.Set("page", strconv.Itoa(page+1))
|
||||
if perPage != 0 {
|
||||
q.Set("per_page", strconv.Itoa(perPage))
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
nextLink = u.String()
|
||||
}
|
||||
|
||||
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp := createSearchResultResponse(total, pds, nextLink)
|
||||
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// EnumeratePackages lists all package names
|
||||
// https://packagist.org/apidoc#list-packages
|
||||
func EnumeratePackages(ctx *context.Context) {
|
||||
ps, err := packages_model.GetPackagesByType(ctx, ctx.Package.Owner.ID, packages_model.TypeComposer)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(ps))
|
||||
for _, p := range ps {
|
||||
names = append(names, p.Name)
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, map[string][]string{
|
||||
"packageNames": names,
|
||||
})
|
||||
}
|
||||
|
||||
// PackageMetadata returns the metadata for a single package
|
||||
// https://packagist.org/apidoc#get-package-data
|
||||
func PackageMetadata(ctx *context.Context) {
|
||||
vendorName := ctx.PathParam("vendorname")
|
||||
projectName := ctx.PathParam("projectname")
|
||||
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeComposer, vendorName+"/"+projectName)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pvs) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, packages_model.ErrPackageNotExist)
|
||||
return
|
||||
}
|
||||
|
||||
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp := createPackageMetadataResponse(
|
||||
setting.AppURL+"api/packages/"+ctx.Package.Owner.Name+"/composer",
|
||||
pds,
|
||||
)
|
||||
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// DownloadPackageFile serves the content of a package
|
||||
func DownloadPackageFile(ctx *context.Context) {
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion(
|
||||
ctx,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeComposer,
|
||||
Name: ctx.PathParam("package"),
|
||||
Version: ctx.PathParam("version"),
|
||||
},
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: ctx.PathParam("filename"),
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(err, packages_model.ErrPackageFileNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
|
||||
// UploadPackage creates a new package
|
||||
func UploadPackage(ctx *context.Context) {
|
||||
buf, err := packages_module.CreateHashedBufferFromReader(ctx.Req.Body)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer buf.Close()
|
||||
|
||||
cp, err := composer_module.ParsePackage(buf, buf.Size())
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := buf.Seek(0, io.SeekStart); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if cp.Version == "" {
|
||||
v, err := version.NewVersion(ctx.FormTrim("version"))
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusBadRequest, composer_module.ErrInvalidVersion)
|
||||
return
|
||||
}
|
||||
cp.Version = v.String()
|
||||
}
|
||||
|
||||
_, _, err = packages_service.CreatePackageAndAddFile(
|
||||
ctx,
|
||||
&packages_service.PackageCreationInfo{
|
||||
PackageInfo: packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeComposer,
|
||||
Name: cp.Name,
|
||||
Version: cp.Version,
|
||||
},
|
||||
SemverCompatible: true,
|
||||
Creator: ctx.Doer,
|
||||
Metadata: cp.Metadata,
|
||||
VersionProperties: map[string]string{
|
||||
composer_module.TypeProperty: cp.Type,
|
||||
},
|
||||
},
|
||||
&packages_service.PackageFileCreationInfo{
|
||||
PackageFileInfo: packages_service.PackageFileInfo{
|
||||
Filename: strings.ToLower(fmt.Sprintf("%s.%s.zip", strings.ReplaceAll(cp.Name, "/", "-"), cp.Version)),
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Data: buf,
|
||||
IsLead: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case packages_model.ErrDuplicatePackageVersion:
|
||||
apiError(ctx, http.StatusConflict, err)
|
||||
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
default:
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusCreated)
|
||||
}
|
||||
45
routers/api/packages/conan/auth.go
Normal file
45
routers/api/packages/conan/auth.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package conan
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/services/auth"
|
||||
"code.gitea.io/gitea/services/packages"
|
||||
)
|
||||
|
||||
var _ auth.Method = &Auth{}
|
||||
|
||||
type Auth struct{}
|
||||
|
||||
func (a *Auth) Name() string {
|
||||
return "conan"
|
||||
}
|
||||
|
||||
// Verify extracts the user from the Bearer token
|
||||
func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataStore, sess auth.SessionStore) (*user_model.User, error) {
|
||||
packageMeta, err := packages.ParseAuthorizationRequest(req)
|
||||
if err != nil {
|
||||
log.Trace("ParseAuthorizationToken: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if packageMeta == nil || packageMeta.UserID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
u, err := user_model.GetUserByID(req.Context(), packageMeta.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if packageMeta.Scope != "" {
|
||||
store.GetData()["IsApiToken"] = true
|
||||
store.GetData()["ApiTokenScope"] = packageMeta.Scope
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
829
routers/api/packages/conan/conan.go
Normal file
829
routers/api/packages/conan/conan.go
Normal file
@@ -0,0 +1,829 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package conan
|
||||
|
||||
import (
|
||||
std_ctx "context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
conan_model "code.gitea.io/gitea/models/packages/conan"
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
packages_module "code.gitea.io/gitea/modules/packages"
|
||||
conan_module "code.gitea.io/gitea/modules/packages/conan"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/routers/api/packages/helper"
|
||||
auth_service "code.gitea.io/gitea/services/auth"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
notify_service "code.gitea.io/gitea/services/notify"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
)
|
||||
|
||||
const (
|
||||
conanfileFile = "conanfile.py"
|
||||
conaninfoFile = "conaninfo.txt"
|
||||
|
||||
recipeReferenceKey = "RecipeReference"
|
||||
packageReferenceKey = "PackageReference"
|
||||
)
|
||||
|
||||
var (
|
||||
recipeFileList = container.SetOf(
|
||||
conanfileFile,
|
||||
"conanmanifest.txt",
|
||||
"conan_sources.tgz",
|
||||
"conan_export.tgz",
|
||||
)
|
||||
packageFileList = container.SetOf(
|
||||
conaninfoFile,
|
||||
"conanmanifest.txt",
|
||||
"conan_package.tgz",
|
||||
)
|
||||
)
|
||||
|
||||
func jsonResponse(ctx *context.Context, status int, obj any) {
|
||||
// https://github.com/conan-io/conan/issues/6613
|
||||
ctx.Resp.Header().Set("Content-Type", "application/json")
|
||||
ctx.Status(status)
|
||||
_ = json.NewEncoder(ctx.Resp).Encode(obj)
|
||||
}
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
jsonResponse(ctx, status, map[string]string{
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
func baseURL(ctx *context.Context) string {
|
||||
return setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/conan"
|
||||
}
|
||||
|
||||
// ExtractPathParameters is a middleware to extract common parameters from path
|
||||
func ExtractPathParameters(ctx *context.Context) {
|
||||
rref, err := conan_module.NewRecipeReference(
|
||||
ctx.PathParam("name"),
|
||||
ctx.PathParam("version"),
|
||||
ctx.PathParam("user"),
|
||||
ctx.PathParam("channel"),
|
||||
ctx.PathParam("recipe_revision"),
|
||||
)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data[recipeReferenceKey] = rref
|
||||
|
||||
reference := ctx.PathParam("package_reference")
|
||||
|
||||
var pref *conan_module.PackageReference
|
||||
if reference != "" {
|
||||
pref, err = conan_module.NewPackageReference(
|
||||
rref,
|
||||
reference,
|
||||
ctx.PathParam("package_revision"),
|
||||
)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Data[packageReferenceKey] = pref
|
||||
}
|
||||
|
||||
// Ping reports the server capabilities
|
||||
func Ping(ctx *context.Context) {
|
||||
ctx.RespHeader().Add("X-Conan-Server-Capabilities", "revisions") // complex_search,checksum_deploy,matrix_params
|
||||
|
||||
ctx.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// Authenticate creates an authentication token for the user
|
||||
func Authenticate(ctx *context.Context) {
|
||||
if ctx.Doer == nil {
|
||||
apiError(ctx, http.StatusBadRequest, nil)
|
||||
return
|
||||
}
|
||||
|
||||
packageScope := auth_service.GetAccessScope(ctx.Data)
|
||||
if has, err := packageScope.HasAnyScope(
|
||||
auth_model.AccessTokenScopeReadPackage,
|
||||
auth_model.AccessTokenScopeWritePackage,
|
||||
auth_model.AccessTokenScopeAll,
|
||||
); !has {
|
||||
if err != nil {
|
||||
log.Error("Error checking access scope: %v", err)
|
||||
}
|
||||
apiError(ctx, http.StatusForbidden, nil)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := packages_service.CreateAuthorizationToken(ctx.Doer, packageScope)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.PlainText(http.StatusOK, token)
|
||||
}
|
||||
|
||||
// CheckCredentials tests if the provided authentication token is valid
|
||||
func CheckCredentials(ctx *context.Context) {
|
||||
if ctx.Doer == nil {
|
||||
ctx.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
packageScope := auth_service.GetAccessScope(ctx.Data)
|
||||
if has, err := packageScope.HasAnyScope(
|
||||
auth_model.AccessTokenScopeReadPackage,
|
||||
auth_model.AccessTokenScopeWritePackage,
|
||||
auth_model.AccessTokenScopeAll,
|
||||
); !has {
|
||||
if err != nil {
|
||||
log.Error("Error checking access scope: %v", err)
|
||||
}
|
||||
ctx.Status(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// RecipeSnapshot displays the recipe files with their md5 hash
|
||||
func RecipeSnapshot(ctx *context.Context) {
|
||||
rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
|
||||
|
||||
serveSnapshot(ctx, rref.AsKey())
|
||||
}
|
||||
|
||||
// RecipeSnapshot displays the package files with their md5 hash
|
||||
func PackageSnapshot(ctx *context.Context) {
|
||||
pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference)
|
||||
|
||||
serveSnapshot(ctx, pref.AsKey())
|
||||
}
|
||||
|
||||
func serveSnapshot(ctx *context.Context, fileKey string) {
|
||||
rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
|
||||
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeConan, rref.Name, rref.Version)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{
|
||||
VersionID: pv.ID,
|
||||
CompositeKey: fileKey,
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pfs) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
files := make(map[string]string)
|
||||
for _, pf := range pfs {
|
||||
pb, err := packages_model.GetBlobByID(ctx, pf.BlobID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
files[pf.Name] = pb.HashMD5
|
||||
}
|
||||
|
||||
jsonResponse(ctx, http.StatusOK, files)
|
||||
}
|
||||
|
||||
// RecipeDownloadURLs displays the recipe files with their download url
|
||||
func RecipeDownloadURLs(ctx *context.Context) {
|
||||
rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
|
||||
|
||||
serveDownloadURLs(
|
||||
ctx,
|
||||
rref.AsKey(),
|
||||
fmt.Sprintf(baseURL(ctx)+"/v1/files/%s/recipe", rref.LinkName()),
|
||||
)
|
||||
}
|
||||
|
||||
// PackageDownloadURLs displays the package files with their download url
|
||||
func PackageDownloadURLs(ctx *context.Context) {
|
||||
pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference)
|
||||
|
||||
serveDownloadURLs(
|
||||
ctx,
|
||||
pref.AsKey(),
|
||||
fmt.Sprintf(baseURL(ctx)+"/v1/files/%s/package/%s", pref.Recipe.LinkName(), pref.LinkName()),
|
||||
)
|
||||
}
|
||||
|
||||
func serveDownloadURLs(ctx *context.Context, fileKey, downloadURL string) {
|
||||
rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
|
||||
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeConan, rref.Name, rref.Version)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{
|
||||
VersionID: pv.ID,
|
||||
CompositeKey: fileKey,
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(pfs) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
urls := make(map[string]string)
|
||||
for _, pf := range pfs {
|
||||
urls[pf.Name] = fmt.Sprintf("%s/%s", downloadURL, pf.Name)
|
||||
}
|
||||
|
||||
jsonResponse(ctx, http.StatusOK, urls)
|
||||
}
|
||||
|
||||
// RecipeUploadURLs displays the upload urls for the provided recipe files
|
||||
func RecipeUploadURLs(ctx *context.Context) {
|
||||
rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
|
||||
|
||||
serveUploadURLs(
|
||||
ctx,
|
||||
recipeFileList,
|
||||
fmt.Sprintf(baseURL(ctx)+"/v1/files/%s/recipe", rref.LinkName()),
|
||||
)
|
||||
}
|
||||
|
||||
// PackageUploadURLs displays the upload urls for the provided package files
|
||||
func PackageUploadURLs(ctx *context.Context) {
|
||||
pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference)
|
||||
|
||||
serveUploadURLs(
|
||||
ctx,
|
||||
packageFileList,
|
||||
fmt.Sprintf(baseURL(ctx)+"/v1/files/%s/package/%s", pref.Recipe.LinkName(), pref.LinkName()),
|
||||
)
|
||||
}
|
||||
|
||||
func serveUploadURLs(ctx *context.Context, fileFilter container.Set[string], uploadURL string) {
|
||||
defer ctx.Req.Body.Close()
|
||||
|
||||
var files map[string]int64
|
||||
if err := json.NewDecoder(ctx.Req.Body).Decode(&files); err != nil {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
urls := make(map[string]string)
|
||||
for file := range files {
|
||||
if fileFilter.Contains(file) {
|
||||
urls[file] = fmt.Sprintf("%s/%s", uploadURL, file)
|
||||
}
|
||||
}
|
||||
|
||||
jsonResponse(ctx, http.StatusOK, urls)
|
||||
}
|
||||
|
||||
// UploadRecipeFile handles the upload of a recipe file
|
||||
func UploadRecipeFile(ctx *context.Context) {
|
||||
rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
|
||||
|
||||
uploadFile(ctx, recipeFileList, rref.AsKey())
|
||||
}
|
||||
|
||||
// UploadPackageFile handles the upload of a package file
|
||||
func UploadPackageFile(ctx *context.Context) {
|
||||
pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference)
|
||||
|
||||
uploadFile(ctx, packageFileList, pref.AsKey())
|
||||
}
|
||||
|
||||
func uploadFile(ctx *context.Context, fileFilter container.Set[string], fileKey string) {
|
||||
rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
|
||||
pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference)
|
||||
|
||||
filename := ctx.PathParam("filename")
|
||||
if !fileFilter.Contains(filename) {
|
||||
apiError(ctx, http.StatusBadRequest, nil)
|
||||
return
|
||||
}
|
||||
|
||||
upload, needToClose, err := ctx.UploadStream()
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if needToClose {
|
||||
defer upload.Close()
|
||||
}
|
||||
|
||||
buf, err := packages_module.CreateHashedBufferFromReader(upload)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer buf.Close()
|
||||
|
||||
isConanfileFile := filename == conanfileFile
|
||||
isConaninfoFile := filename == conaninfoFile
|
||||
|
||||
pci := &packages_service.PackageCreationInfo{
|
||||
PackageInfo: packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeConan,
|
||||
Name: rref.Name,
|
||||
Version: rref.Version,
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
}
|
||||
pfci := &packages_service.PackageFileCreationInfo{
|
||||
PackageFileInfo: packages_service.PackageFileInfo{
|
||||
Filename: strings.ToLower(filename),
|
||||
CompositeKey: fileKey,
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Data: buf,
|
||||
IsLead: isConanfileFile,
|
||||
Properties: map[string]string{
|
||||
conan_module.PropertyRecipeUser: rref.User,
|
||||
conan_module.PropertyRecipeChannel: rref.Channel,
|
||||
conan_module.PropertyRecipeRevision: rref.RevisionOrDefault(),
|
||||
},
|
||||
OverwriteExisting: true,
|
||||
}
|
||||
|
||||
if pref != nil {
|
||||
pfci.Properties[conan_module.PropertyPackageReference] = pref.Reference
|
||||
pfci.Properties[conan_module.PropertyPackageRevision] = pref.RevisionOrDefault()
|
||||
}
|
||||
|
||||
if isConanfileFile || isConaninfoFile {
|
||||
if isConanfileFile {
|
||||
metadata, err := conan_module.ParseConanfile(buf)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, pci.Owner.ID, pci.PackageType, pci.Name, pci.Version)
|
||||
if err != nil && err != packages_model.ErrPackageNotExist {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if pv != nil {
|
||||
raw, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
pv.MetadataJSON = string(raw)
|
||||
if err := packages_model.UpdateVersion(ctx, pv); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
pci.Metadata = metadata
|
||||
}
|
||||
} else {
|
||||
info, err := conan_module.ParseConaninfo(buf)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(info)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
pfci.Properties[conan_module.PropertyPackageInfo] = string(raw)
|
||||
}
|
||||
|
||||
if _, err := buf.Seek(0, io.SeekStart); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_, _, err = packages_service.CreatePackageOrAddFileToExisting(
|
||||
ctx,
|
||||
pci,
|
||||
pfci,
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case packages_model.ErrDuplicatePackageFile:
|
||||
apiError(ctx, http.StatusConflict, err)
|
||||
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
default:
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusCreated)
|
||||
}
|
||||
|
||||
// DownloadRecipeFile serves the content of the requested recipe file
|
||||
func DownloadRecipeFile(ctx *context.Context) {
|
||||
rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
|
||||
|
||||
downloadFile(ctx, recipeFileList, rref.AsKey())
|
||||
}
|
||||
|
||||
// DownloadPackageFile serves the content of the requested package file
|
||||
func DownloadPackageFile(ctx *context.Context) {
|
||||
pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference)
|
||||
|
||||
downloadFile(ctx, packageFileList, pref.AsKey())
|
||||
}
|
||||
|
||||
func downloadFile(ctx *context.Context, fileFilter container.Set[string], fileKey string) {
|
||||
rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
|
||||
|
||||
filename := ctx.PathParam("filename")
|
||||
if !fileFilter.Contains(filename) {
|
||||
apiError(ctx, http.StatusBadRequest, nil)
|
||||
return
|
||||
}
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion(
|
||||
ctx,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeConan,
|
||||
Name: rref.Name,
|
||||
Version: rref.Version,
|
||||
},
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: filename,
|
||||
CompositeKey: fileKey,
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(err, packages_model.ErrPackageFileNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
|
||||
// DeleteRecipeV1 deletes the requested recipe(s)
|
||||
func DeleteRecipeV1(ctx *context.Context) {
|
||||
rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
|
||||
|
||||
if err := deleteRecipeOrPackage(ctx, rref, true, nil, false); err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(err, conan_model.ErrPackageReferenceNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// DeleteRecipeV2 deletes the requested recipe(s) respecting its revisions
|
||||
func DeleteRecipeV2(ctx *context.Context) {
|
||||
rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
|
||||
|
||||
if err := deleteRecipeOrPackage(ctx, rref, rref.Revision == "", nil, false); err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(err, conan_model.ErrPackageReferenceNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// DeletePackageV1 deletes the requested package(s)
|
||||
func DeletePackageV1(ctx *context.Context) {
|
||||
rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
|
||||
|
||||
type PackageReferences struct {
|
||||
References []string `json:"package_ids"`
|
||||
}
|
||||
|
||||
var ids *PackageReferences
|
||||
if err := json.NewDecoder(ctx.Req.Body).Decode(&ids); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
revisions, err := conan_model.GetRecipeRevisions(ctx, ctx.Package.Owner.ID, rref)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
for _, revision := range revisions {
|
||||
currentRref := rref.WithRevision(revision.Value)
|
||||
|
||||
var references []*conan_model.PropertyValue
|
||||
if len(ids.References) == 0 {
|
||||
if references, err = conan_model.GetPackageReferences(ctx, ctx.Package.Owner.ID, currentRref); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
for _, reference := range ids.References {
|
||||
references = append(references, &conan_model.PropertyValue{Value: reference})
|
||||
}
|
||||
}
|
||||
|
||||
for _, reference := range references {
|
||||
pref, _ := conan_module.NewPackageReference(currentRref, reference.Value, conan_module.DefaultRevision)
|
||||
if err := deleteRecipeOrPackage(ctx, currentRref, true, pref, true); err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(err, conan_model.ErrPackageReferenceNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// DeletePackageV2 deletes the requested package(s) respecting its revisions
|
||||
func DeletePackageV2(ctx *context.Context) {
|
||||
rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
|
||||
pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference)
|
||||
|
||||
if pref != nil { // has package reference
|
||||
if err := deleteRecipeOrPackage(ctx, rref, false, pref, pref.Revision == ""); err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(err, conan_model.ErrPackageReferenceNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
} else {
|
||||
ctx.Status(http.StatusOK)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
references, err := conan_model.GetPackageReferences(ctx, ctx.Package.Owner.ID, rref)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(references) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, conan_model.ErrPackageReferenceNotExist)
|
||||
return
|
||||
}
|
||||
|
||||
for _, reference := range references {
|
||||
pref, _ := conan_module.NewPackageReference(rref, reference.Value, conan_module.DefaultRevision)
|
||||
|
||||
if err := deleteRecipeOrPackage(ctx, rref, false, pref, true); err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(err, conan_model.ErrPackageReferenceNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func deleteRecipeOrPackage(apictx *context.Context, rref *conan_module.RecipeReference, ignoreRecipeRevision bool, pref *conan_module.PackageReference, ignorePackageRevision bool) error {
|
||||
var pd *packages_model.PackageDescriptor
|
||||
versionDeleted := false
|
||||
|
||||
err := db.WithTx(apictx, func(ctx std_ctx.Context) error {
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, apictx.Package.Owner.ID, packages_model.TypeConan, rref.Name, rref.Version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pd, err = packages_model.GetPackageDescriptor(ctx, pv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filter := map[string]string{
|
||||
conan_module.PropertyRecipeUser: rref.User,
|
||||
conan_module.PropertyRecipeChannel: rref.Channel,
|
||||
}
|
||||
if !ignoreRecipeRevision {
|
||||
filter[conan_module.PropertyRecipeRevision] = rref.RevisionOrDefault()
|
||||
}
|
||||
if pref != nil {
|
||||
filter[conan_module.PropertyPackageReference] = pref.Reference
|
||||
if !ignorePackageRevision {
|
||||
filter[conan_module.PropertyPackageRevision] = pref.RevisionOrDefault()
|
||||
}
|
||||
}
|
||||
|
||||
pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{
|
||||
VersionID: pv.ID,
|
||||
Properties: filter,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(pfs) == 0 {
|
||||
return conan_model.ErrPackageReferenceNotExist
|
||||
}
|
||||
|
||||
for _, pf := range pfs {
|
||||
if err := packages_service.DeletePackageFile(ctx, pf); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
has, err := packages_model.HasVersionFileReferences(ctx, pv.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !has {
|
||||
versionDeleted = true
|
||||
|
||||
return packages_service.DeletePackageVersionAndReferences(ctx, pv)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if versionDeleted {
|
||||
notify_service.PackageDelete(apictx, apictx.Doer, pd)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListRecipeRevisions gets a list of all recipe revisions
|
||||
func ListRecipeRevisions(ctx *context.Context) {
|
||||
rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
|
||||
|
||||
revisions, err := conan_model.GetRecipeRevisions(ctx, ctx.Package.Owner.ID, rref)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
listRevisions(ctx, revisions)
|
||||
}
|
||||
|
||||
// ListPackageRevisions gets a list of all package revisions
|
||||
func ListPackageRevisions(ctx *context.Context) {
|
||||
pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference)
|
||||
|
||||
revisions, err := conan_model.GetPackageRevisions(ctx, ctx.Package.Owner.ID, pref)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
listRevisions(ctx, revisions)
|
||||
}
|
||||
|
||||
type revisionInfo struct {
|
||||
Revision string `json:"revision"`
|
||||
Time time.Time `json:"time"`
|
||||
}
|
||||
|
||||
func listRevisions(ctx *context.Context, revisions []*conan_model.PropertyValue) {
|
||||
if len(revisions) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, conan_model.ErrRecipeReferenceNotExist)
|
||||
return
|
||||
}
|
||||
|
||||
type RevisionList struct {
|
||||
Revisions []*revisionInfo `json:"revisions"`
|
||||
}
|
||||
|
||||
revs := make([]*revisionInfo, 0, len(revisions))
|
||||
for _, rev := range revisions {
|
||||
revs = append(revs, &revisionInfo{Revision: rev.Value, Time: rev.CreatedUnix.AsLocalTime()})
|
||||
}
|
||||
|
||||
jsonResponse(ctx, http.StatusOK, &RevisionList{revs})
|
||||
}
|
||||
|
||||
// LatestRecipeRevision gets the latest recipe revision
|
||||
func LatestRecipeRevision(ctx *context.Context) {
|
||||
rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
|
||||
|
||||
revision, err := conan_model.GetLastRecipeRevision(ctx, ctx.Package.Owner.ID, rref)
|
||||
if err != nil {
|
||||
if errors.Is(err, conan_model.ErrRecipeReferenceNotExist) || errors.Is(err, conan_model.ErrPackageReferenceNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(ctx, http.StatusOK, &revisionInfo{Revision: revision.Value, Time: revision.CreatedUnix.AsLocalTime()})
|
||||
}
|
||||
|
||||
// LatestPackageRevision gets the latest package revision
|
||||
func LatestPackageRevision(ctx *context.Context) {
|
||||
pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference)
|
||||
|
||||
revision, err := conan_model.GetLastPackageRevision(ctx, ctx.Package.Owner.ID, pref)
|
||||
if err != nil {
|
||||
if errors.Is(err, conan_model.ErrRecipeReferenceNotExist) || errors.Is(err, conan_model.ErrPackageReferenceNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(ctx, http.StatusOK, &revisionInfo{Revision: revision.Value, Time: revision.CreatedUnix.AsLocalTime()})
|
||||
}
|
||||
|
||||
// ListRecipeRevisionFiles gets a list of all recipe revision files
|
||||
func ListRecipeRevisionFiles(ctx *context.Context) {
|
||||
rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
|
||||
|
||||
listRevisionFiles(ctx, rref.AsKey())
|
||||
}
|
||||
|
||||
// ListPackageRevisionFiles gets a list of all package revision files
|
||||
func ListPackageRevisionFiles(ctx *context.Context) {
|
||||
pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference)
|
||||
|
||||
listRevisionFiles(ctx, pref.AsKey())
|
||||
}
|
||||
|
||||
func listRevisionFiles(ctx *context.Context, fileKey string) {
|
||||
rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
|
||||
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeConan, rref.Name, rref.Version)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{
|
||||
VersionID: pv.ID,
|
||||
CompositeKey: fileKey,
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pfs) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
files := make(map[string]any)
|
||||
for _, pf := range pfs {
|
||||
files[pf.Name] = nil
|
||||
}
|
||||
|
||||
type FileList struct {
|
||||
Files map[string]any `json:"files"`
|
||||
}
|
||||
|
||||
jsonResponse(ctx, http.StatusOK, &FileList{
|
||||
Files: files,
|
||||
})
|
||||
}
|
||||
164
routers/api/packages/conan/search.go
Normal file
164
routers/api/packages/conan/search.go
Normal file
@@ -0,0 +1,164 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package conan
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
conan_model "code.gitea.io/gitea/models/packages/conan"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
conan_module "code.gitea.io/gitea/modules/packages/conan"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
// SearchResult contains the found recipe names
|
||||
type SearchResult struct {
|
||||
Results []string `json:"results"`
|
||||
}
|
||||
|
||||
// SearchRecipes searches all recipes matching the query
|
||||
func SearchRecipes(ctx *context.Context) {
|
||||
q := ctx.FormTrim("q")
|
||||
|
||||
opts := parseQuery(ctx.Package.Owner, q)
|
||||
|
||||
results, err := conan_model.SearchRecipes(ctx, opts)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(ctx, http.StatusOK, &SearchResult{
|
||||
Results: results,
|
||||
})
|
||||
}
|
||||
|
||||
// parseQuery creates search options for the given query
|
||||
func parseQuery(owner *user_model.User, query string) *conan_model.RecipeSearchOptions {
|
||||
opts := &conan_model.RecipeSearchOptions{
|
||||
OwnerID: owner.ID,
|
||||
}
|
||||
|
||||
if query != "" {
|
||||
parts := strings.Split(strings.ReplaceAll(query, "@", "/"), "/")
|
||||
|
||||
opts.Name = parts[0]
|
||||
if len(parts) > 1 && parts[1] != "*" {
|
||||
opts.Version = parts[1]
|
||||
}
|
||||
if len(parts) > 2 && parts[2] != "*" {
|
||||
opts.User = parts[2]
|
||||
}
|
||||
if len(parts) > 3 && parts[3] != "*" {
|
||||
opts.Channel = parts[3]
|
||||
}
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
// SearchPackagesV1 searches all packages of a recipe (Conan v1 endpoint)
|
||||
func SearchPackagesV1(ctx *context.Context) {
|
||||
searchPackages(ctx, true)
|
||||
}
|
||||
|
||||
// SearchPackagesV2 searches all packages of a recipe (Conan v2 endpoint)
|
||||
func SearchPackagesV2(ctx *context.Context) {
|
||||
searchPackages(ctx, false)
|
||||
}
|
||||
|
||||
func searchPackages(ctx *context.Context, searchAllRevisions bool) {
|
||||
rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
|
||||
|
||||
if !searchAllRevisions && rref.Revision == "" {
|
||||
lastRevision, err := conan_model.GetLastRecipeRevision(ctx, ctx.Package.Owner.ID, rref)
|
||||
if err != nil {
|
||||
if errors.Is(err, conan_model.ErrRecipeReferenceNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
rref = rref.WithRevision(lastRevision.Value)
|
||||
} else {
|
||||
has, err := conan_model.RecipeExists(ctx, ctx.Package.Owner.ID, rref)
|
||||
if err != nil {
|
||||
if errors.Is(err, conan_model.ErrRecipeReferenceNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !has {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
recipeRevisions := []*conan_model.PropertyValue{{Value: rref.Revision}}
|
||||
if searchAllRevisions {
|
||||
var err error
|
||||
recipeRevisions, err = conan_model.GetRecipeRevisions(ctx, ctx.Package.Owner.ID, rref)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
result := make(map[string]*conan_module.Conaninfo)
|
||||
|
||||
for _, recipeRevision := range recipeRevisions {
|
||||
currentRef := rref
|
||||
if recipeRevision.Value != "" {
|
||||
currentRef = rref.WithRevision(recipeRevision.Value)
|
||||
}
|
||||
packageReferences, err := conan_model.GetPackageReferences(ctx, ctx.Package.Owner.ID, currentRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, conan_model.ErrRecipeReferenceNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, packageReference := range packageReferences {
|
||||
if _, ok := result[packageReference.Value]; ok {
|
||||
continue
|
||||
}
|
||||
pref, _ := conan_module.NewPackageReference(currentRef, packageReference.Value, "")
|
||||
lastPackageRevision, err := conan_model.GetLastPackageRevision(ctx, ctx.Package.Owner.ID, pref)
|
||||
if err != nil {
|
||||
if errors.Is(err, conan_model.ErrPackageReferenceNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
pref = pref.WithRevision(lastPackageRevision.Value)
|
||||
infoRaw, err := conan_model.GetPackageInfo(ctx, ctx.Package.Owner.ID, pref)
|
||||
if err != nil {
|
||||
if errors.Is(err, conan_model.ErrPackageReferenceNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
var info *conan_module.Conaninfo
|
||||
if err := json.Unmarshal([]byte(infoRaw), &info); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
result[pref.Reference] = info
|
||||
}
|
||||
}
|
||||
|
||||
jsonResponse(ctx, http.StatusOK, result)
|
||||
}
|
||||
322
routers/api/packages/conda/conda.go
Normal file
322
routers/api/packages/conda/conda.go
Normal file
@@ -0,0 +1,322 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package conda
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
conda_model "code.gitea.io/gitea/models/packages/conda"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
packages_module "code.gitea.io/gitea/modules/packages"
|
||||
conda_module "code.gitea.io/gitea/modules/packages/conda"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/routers/api/packages/helper"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
|
||||
"github.com/dsnet/compress/bzip2"
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
ctx.JSON(status, struct {
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
}{
|
||||
Reason: http.StatusText(status),
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
func isCondaPackageFileName(filename string) bool {
|
||||
return strings.HasSuffix(filename, ".tar.bz2") || strings.HasSuffix(filename, ".conda")
|
||||
}
|
||||
|
||||
func ListOrGetPackages(ctx *context.Context) {
|
||||
filename := ctx.PathParam("filename")
|
||||
switch filename {
|
||||
case "repodata.json", "repodata.json.bz2", "current_repodata.json", "current_repodata.json.bz2":
|
||||
EnumeratePackages(ctx)
|
||||
return
|
||||
}
|
||||
if isCondaPackageFileName(filename) {
|
||||
DownloadPackageFile(ctx)
|
||||
return
|
||||
}
|
||||
http.NotFound(ctx.Resp, ctx.Req)
|
||||
}
|
||||
|
||||
func EnumeratePackages(ctx *context.Context) {
|
||||
type Info struct {
|
||||
Subdir string `json:"subdir"`
|
||||
}
|
||||
|
||||
type PackageInfo struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
NoArch string `json:"noarch"`
|
||||
Subdir string `json:"subdir"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Build string `json:"build"`
|
||||
BuildNumber int64 `json:"build_number"`
|
||||
Dependencies []string `json:"depends"`
|
||||
License string `json:"license"`
|
||||
LicenseFamily string `json:"license_family"`
|
||||
HashMD5 string `json:"md5"`
|
||||
HashSHA256 string `json:"sha256"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
type RepoData struct {
|
||||
Info Info `json:"info"`
|
||||
Packages map[string]*PackageInfo `json:"packages"`
|
||||
PackagesConda map[string]*PackageInfo `json:"packages.conda"`
|
||||
Removed map[string]*PackageInfo `json:"removed"`
|
||||
}
|
||||
|
||||
repoData := &RepoData{
|
||||
Info: Info{
|
||||
Subdir: ctx.PathParam("architecture"),
|
||||
},
|
||||
Packages: make(map[string]*PackageInfo),
|
||||
PackagesConda: make(map[string]*PackageInfo),
|
||||
Removed: make(map[string]*PackageInfo),
|
||||
}
|
||||
|
||||
pfs, err := conda_model.SearchFiles(ctx, &conda_model.FileSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Channel: ctx.PathParam("channel"),
|
||||
Subdir: repoData.Info.Subdir,
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(pfs) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
pds := make(map[int64]*packages_model.PackageDescriptor)
|
||||
|
||||
for _, pf := range pfs {
|
||||
pd, exists := pds[pf.VersionID]
|
||||
if !exists {
|
||||
pv, err := packages_model.GetVersionByID(ctx, pf.VersionID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
pd, err = packages_model.GetPackageDescriptor(ctx, pv)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
pds[pf.VersionID] = pd
|
||||
}
|
||||
|
||||
var pfd *packages_model.PackageFileDescriptor
|
||||
for _, d := range pd.Files {
|
||||
if d.File.ID == pf.ID {
|
||||
pfd = d
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var fileMetadata *conda_module.FileMetadata
|
||||
if err := json.Unmarshal([]byte(pfd.Properties.GetByName(conda_module.PropertyMetadata)), &fileMetadata); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
versionMetadata := pd.Metadata.(*conda_module.VersionMetadata)
|
||||
|
||||
pi := &PackageInfo{
|
||||
Name: pd.PackageProperties.GetByName(conda_module.PropertyName),
|
||||
Version: pd.Version.Version,
|
||||
NoArch: fileMetadata.NoArch,
|
||||
Subdir: repoData.Info.Subdir,
|
||||
Timestamp: fileMetadata.Timestamp,
|
||||
Build: fileMetadata.Build,
|
||||
BuildNumber: fileMetadata.BuildNumber,
|
||||
Dependencies: fileMetadata.Dependencies,
|
||||
License: versionMetadata.License,
|
||||
LicenseFamily: versionMetadata.LicenseFamily,
|
||||
HashMD5: pfd.Blob.HashMD5,
|
||||
HashSHA256: pfd.Blob.HashSHA256,
|
||||
Size: pfd.Blob.Size,
|
||||
}
|
||||
|
||||
if fileMetadata.IsCondaPackage {
|
||||
repoData.PackagesConda[pfd.File.Name] = pi
|
||||
} else {
|
||||
repoData.Packages[pfd.File.Name] = pi
|
||||
}
|
||||
}
|
||||
|
||||
resp := ctx.Resp
|
||||
|
||||
var w io.Writer = resp
|
||||
|
||||
if strings.HasSuffix(ctx.PathParam("filename"), ".json") {
|
||||
resp.Header().Set("Content-Type", "application/json")
|
||||
} else {
|
||||
resp.Header().Set("Content-Type", "application/x-bzip2")
|
||||
|
||||
zw, err := bzip2.NewWriter(w, nil)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer zw.Close()
|
||||
|
||||
w = zw
|
||||
}
|
||||
|
||||
resp.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(repoData)
|
||||
}
|
||||
|
||||
func UploadPackageFile(ctx *context.Context) {
|
||||
filename := ctx.PathParam("filename")
|
||||
if !isCondaPackageFileName(filename) {
|
||||
apiError(ctx, http.StatusBadRequest, nil)
|
||||
return
|
||||
}
|
||||
|
||||
upload, needToClose, err := ctx.UploadStream()
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if needToClose {
|
||||
defer upload.Close()
|
||||
}
|
||||
|
||||
buf, err := packages_module.CreateHashedBufferFromReader(upload)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer buf.Close()
|
||||
|
||||
var pck *conda_module.Package
|
||||
if strings.HasSuffix(filename, ".tar.bz2") {
|
||||
pck, err = conda_module.ParsePackageBZ2(buf)
|
||||
} else {
|
||||
pck, err = conda_module.ParsePackageConda(buf, buf.Size())
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := buf.Seek(0, io.SeekStart); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
fullName := pck.Name
|
||||
|
||||
channel := ctx.PathParam("channel")
|
||||
if channel != "" {
|
||||
fullName = channel + "/" + pck.Name
|
||||
}
|
||||
|
||||
extension := ".tar.bz2"
|
||||
if pck.FileMetadata.IsCondaPackage {
|
||||
extension = ".conda"
|
||||
}
|
||||
|
||||
fileMetadataRaw, err := json.Marshal(pck.FileMetadata)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
_, _, err = packages_service.CreatePackageOrAddFileToExisting(
|
||||
ctx,
|
||||
&packages_service.PackageCreationInfo{
|
||||
PackageInfo: packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeConda,
|
||||
Name: fullName,
|
||||
Version: pck.Version,
|
||||
},
|
||||
SemverCompatible: false,
|
||||
Creator: ctx.Doer,
|
||||
Metadata: pck.VersionMetadata,
|
||||
PackageProperties: map[string]string{
|
||||
conda_module.PropertyName: pck.Name,
|
||||
conda_module.PropertyChannel: channel,
|
||||
},
|
||||
},
|
||||
&packages_service.PackageFileCreationInfo{
|
||||
PackageFileInfo: packages_service.PackageFileInfo{
|
||||
Filename: fmt.Sprintf("%s-%s-%s%s", pck.Name, pck.Version, pck.FileMetadata.Build, extension),
|
||||
CompositeKey: pck.Subdir,
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Data: buf,
|
||||
IsLead: true,
|
||||
Properties: map[string]string{
|
||||
conda_module.PropertySubdir: pck.Subdir,
|
||||
conda_module.PropertyMetadata: string(fileMetadataRaw),
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case packages_model.ErrDuplicatePackageFile:
|
||||
apiError(ctx, http.StatusConflict, err)
|
||||
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
default:
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusCreated)
|
||||
}
|
||||
|
||||
func DownloadPackageFile(ctx *context.Context) {
|
||||
pfs, err := conda_model.SearchFiles(ctx, &conda_model.FileSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Channel: ctx.PathParam("channel"),
|
||||
Subdir: ctx.PathParam("architecture"),
|
||||
Filename: ctx.PathParam("filename"),
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(pfs) != 1 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
pf := pfs[0]
|
||||
|
||||
s, u, _, err := packages_service.OpenFileForDownload(ctx, pf, ctx.Req.Method)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
47
routers/api/packages/container/auth.go
Normal file
47
routers/api/packages/container/auth.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/services/auth"
|
||||
"code.gitea.io/gitea/services/packages"
|
||||
)
|
||||
|
||||
var _ auth.Method = &Auth{}
|
||||
|
||||
type Auth struct{}
|
||||
|
||||
func (a *Auth) Name() string {
|
||||
return "container"
|
||||
}
|
||||
|
||||
// Verify extracts the user from the Bearer token
|
||||
// If it's an anonymous session, a ghost user is returned
|
||||
func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataStore, sess auth.SessionStore) (*user_model.User, error) {
|
||||
packageMeta, err := packages.ParseAuthorizationRequest(req)
|
||||
if err != nil {
|
||||
log.Trace("ParseAuthorizationToken: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if packageMeta == nil || packageMeta.UserID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
u, err := user_model.GetPossibleUserByID(req.Context(), packageMeta.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if packageMeta.Scope != "" {
|
||||
store.GetData()["IsApiToken"] = true
|
||||
store.GetData()["ApiTokenScope"] = packageMeta.Scope
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
206
routers/api/packages/container/blob.go
Normal file
206
routers/api/packages/container/blob.go
Normal file
@@ -0,0 +1,206 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
container_model "code.gitea.io/gitea/models/packages/container"
|
||||
"code.gitea.io/gitea/modules/globallock"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
packages_module "code.gitea.io/gitea/modules/packages"
|
||||
container_module "code.gitea.io/gitea/modules/packages/container"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
|
||||
"github.com/opencontainers/go-digest"
|
||||
)
|
||||
|
||||
// saveAsPackageBlob creates a package blob from an upload
|
||||
// The uploaded blob gets stored in a special upload version to link them to the package/image
|
||||
func saveAsPackageBlob(ctx context.Context, hsr packages_module.HashedSizeReader, pci *packages_service.PackageCreationInfo) (*packages_model.PackageBlob, error) { //nolint:unparam // PackageBlob is never used
|
||||
pb := packages_service.NewPackageBlob(hsr)
|
||||
|
||||
exists := false
|
||||
|
||||
contentStore := packages_module.NewContentStore()
|
||||
|
||||
uploadVersion, err := getOrCreateUploadVersion(ctx, &pci.PackageInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = db.WithTx(ctx, func(ctx context.Context) error {
|
||||
if err := packages_service.CheckSizeQuotaExceeded(ctx, pci.Creator, pci.Owner, packages_model.TypeContainer, hsr.Size()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pb, exists, err = packages_model.GetOrInsertBlob(ctx, pb)
|
||||
if err != nil {
|
||||
log.Error("Error inserting package blob: %v", err)
|
||||
return err
|
||||
}
|
||||
// FIXME: Workaround to be removed in v1.20
|
||||
// https://github.com/go-gitea/gitea/issues/19586
|
||||
if exists {
|
||||
err = contentStore.Has(packages_module.BlobHash256Key(pb.HashSHA256))
|
||||
if err != nil && (errors.Is(err, util.ErrNotExist) || errors.Is(err, os.ErrNotExist)) {
|
||||
log.Debug("Package registry inconsistent: blob %s does not exist on file system", pb.HashSHA256)
|
||||
exists = false
|
||||
}
|
||||
}
|
||||
if !exists {
|
||||
if err := contentStore.Save(packages_module.BlobHash256Key(pb.HashSHA256), hsr, hsr.Size()); err != nil {
|
||||
log.Error("Error saving package blob in content store: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return createFileForBlob(ctx, uploadVersion, pb)
|
||||
})
|
||||
if err != nil {
|
||||
if !exists {
|
||||
if err := contentStore.Delete(packages_module.BlobHash256Key(pb.HashSHA256)); err != nil {
|
||||
log.Error("Error deleting package blob from content store: %v", err)
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return pb, nil
|
||||
}
|
||||
|
||||
// mountBlob mounts the specific blob to a different package
|
||||
func mountBlob(ctx context.Context, pi *packages_service.PackageInfo, pb *packages_model.PackageBlob) error {
|
||||
uploadVersion, err := getOrCreateUploadVersion(ctx, pi)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
return createFileForBlob(ctx, uploadVersion, pb)
|
||||
})
|
||||
}
|
||||
|
||||
func containerGlobalLockKey(piOwnerID int64, piName, usage string) string {
|
||||
return fmt.Sprintf("pkg_%d_container_%s_%s", piOwnerID, strings.ToLower(piName), usage)
|
||||
}
|
||||
|
||||
func getOrCreateUploadVersion(ctx context.Context, pi *packages_service.PackageInfo) (*packages_model.PackageVersion, error) {
|
||||
releaser, err := globallock.Lock(ctx, containerGlobalLockKey(pi.Owner.ID, pi.Name, "package"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer releaser()
|
||||
|
||||
return db.WithTx2(ctx, func(ctx context.Context) (*packages_model.PackageVersion, error) {
|
||||
created := true
|
||||
p := &packages_model.Package{
|
||||
OwnerID: pi.Owner.ID,
|
||||
Type: packages_model.TypeContainer,
|
||||
Name: strings.ToLower(pi.Name),
|
||||
LowerName: strings.ToLower(pi.Name),
|
||||
}
|
||||
var err error
|
||||
if p, err = packages_model.TryInsertPackage(ctx, p); err != nil {
|
||||
if !errors.Is(err, packages_model.ErrDuplicatePackage) {
|
||||
log.Error("Error inserting package: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
created = false
|
||||
}
|
||||
|
||||
if created {
|
||||
if _, err := packages_model.InsertProperty(ctx, packages_model.PropertyTypePackage, p.ID, container_module.PropertyRepository, strings.ToLower(pi.Owner.LowerName+"/"+pi.Name)); err != nil {
|
||||
log.Error("Error setting package property: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
pv := &packages_model.PackageVersion{
|
||||
PackageID: p.ID,
|
||||
CreatorID: pi.Owner.ID,
|
||||
Version: container_module.UploadVersion,
|
||||
LowerVersion: container_module.UploadVersion,
|
||||
IsInternal: true,
|
||||
MetadataJSON: "null",
|
||||
}
|
||||
if pv, err = packages_model.GetOrInsertVersion(ctx, pv); err != nil {
|
||||
if !errors.Is(err, packages_model.ErrDuplicatePackageVersion) {
|
||||
log.Error("Error inserting package: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return pv, nil
|
||||
})
|
||||
}
|
||||
|
||||
func createFileForBlob(ctx context.Context, pv *packages_model.PackageVersion, pb *packages_model.PackageBlob) error {
|
||||
filename := strings.ToLower("sha256_" + pb.HashSHA256)
|
||||
|
||||
pf := &packages_model.PackageFile{
|
||||
VersionID: pv.ID,
|
||||
BlobID: pb.ID,
|
||||
Name: filename,
|
||||
LowerName: filename,
|
||||
CompositeKey: packages_model.EmptyFileKey,
|
||||
}
|
||||
var err error
|
||||
if pf, err = packages_model.TryInsertFile(ctx, pf); err != nil {
|
||||
if errors.Is(err, packages_model.ErrDuplicatePackageFile) {
|
||||
return nil
|
||||
}
|
||||
log.Error("Error inserting package file: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := packages_model.InsertProperty(ctx, packages_model.PropertyTypeFile, pf.ID, container_module.PropertyDigest, digestFromPackageBlob(pb)); err != nil {
|
||||
log.Error("Error setting package file property: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteBlob(ctx context.Context, ownerID int64, image string, digest digest.Digest) error {
|
||||
releaser, err := globallock.Lock(ctx, containerGlobalLockKey(ownerID, image, "blob"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer releaser()
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
pfds, err := container_model.GetContainerBlobs(ctx, &container_model.BlobSearchOptions{
|
||||
OwnerID: ownerID,
|
||||
Image: image,
|
||||
Digest: string(digest),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, file := range pfds {
|
||||
if err := packages_service.DeletePackageFile(ctx, file.File); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func digestFromHashSummer(h packages_module.HashSummer) string {
|
||||
_, _, hashSHA256, _ := h.Sums()
|
||||
return "sha256:" + hex.EncodeToString(hashSHA256)
|
||||
}
|
||||
|
||||
func digestFromPackageBlob(pb *packages_model.PackageBlob) string {
|
||||
return "sha256:" + pb.HashSHA256
|
||||
}
|
||||
804
routers/api/packages/container/container.go
Normal file
804
routers/api/packages/container/container.go
Normal file
@@ -0,0 +1,804 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
container_model "code.gitea.io/gitea/models/packages/container"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/httplib"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
packages_module "code.gitea.io/gitea/modules/packages"
|
||||
container_module "code.gitea.io/gitea/modules/packages/container"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/routers/api/packages/helper"
|
||||
auth_service "code.gitea.io/gitea/services/auth"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
container_service "code.gitea.io/gitea/services/packages/container"
|
||||
|
||||
"github.com/opencontainers/go-digest"
|
||||
)
|
||||
|
||||
// maximum size of a container manifest
|
||||
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-manifests
|
||||
const maxManifestSize = 10 * 1024 * 1024
|
||||
|
||||
var globalVars = sync.OnceValue(func() (ret struct {
|
||||
imageNamePattern, referencePattern *regexp.Regexp
|
||||
},
|
||||
) {
|
||||
ret.imageNamePattern = regexp.MustCompile(`\A[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*\z`)
|
||||
ret.referencePattern = regexp.MustCompile(`\A[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}\z`)
|
||||
return ret
|
||||
})
|
||||
|
||||
type containerHeaders struct {
|
||||
Status int
|
||||
ContentDigest string
|
||||
UploadUUID string
|
||||
Range string
|
||||
Location string
|
||||
ContentType string
|
||||
ContentLength optional.Option[int64]
|
||||
}
|
||||
|
||||
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#legacy-docker-support-http-headers
|
||||
func setResponseHeaders(resp http.ResponseWriter, h *containerHeaders) {
|
||||
if h.Location != "" {
|
||||
resp.Header().Set("Location", h.Location)
|
||||
}
|
||||
if h.Range != "" {
|
||||
resp.Header().Set("Range", h.Range)
|
||||
}
|
||||
if h.ContentType != "" {
|
||||
resp.Header().Set("Content-Type", h.ContentType)
|
||||
}
|
||||
if h.ContentLength.Has() {
|
||||
resp.Header().Set("Content-Length", strconv.FormatInt(h.ContentLength.Value(), 10))
|
||||
}
|
||||
if h.UploadUUID != "" {
|
||||
resp.Header().Set("Docker-Upload-Uuid", h.UploadUUID)
|
||||
}
|
||||
if h.ContentDigest != "" {
|
||||
resp.Header().Set("Docker-Content-Digest", h.ContentDigest)
|
||||
resp.Header().Set("ETag", fmt.Sprintf(`"%s"`, h.ContentDigest))
|
||||
}
|
||||
resp.Header().Set("Docker-Distribution-Api-Version", "registry/2.0")
|
||||
resp.WriteHeader(h.Status)
|
||||
}
|
||||
|
||||
func jsonResponse(ctx *context.Context, status int, obj any) {
|
||||
setResponseHeaders(ctx.Resp, &containerHeaders{
|
||||
Status: status,
|
||||
ContentType: "application/json",
|
||||
})
|
||||
_ = json.NewEncoder(ctx.Resp).Encode(obj) // ignore network errors
|
||||
}
|
||||
|
||||
func apiError(ctx *context.Context, status int, err error) {
|
||||
_ = helper.ProcessErrorForUser(ctx, status, err)
|
||||
setResponseHeaders(ctx.Resp, &containerHeaders{
|
||||
Status: status,
|
||||
})
|
||||
}
|
||||
|
||||
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#error-codes
|
||||
func apiErrorDefined(ctx *context.Context, err *namedError) {
|
||||
type ContainerError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type ContainerErrors struct {
|
||||
Errors []ContainerError `json:"errors"`
|
||||
}
|
||||
|
||||
jsonResponse(ctx, err.StatusCode, ContainerErrors{
|
||||
Errors: []ContainerError{
|
||||
{
|
||||
Code: err.Code,
|
||||
Message: err.Message,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func apiUnauthorizedError(ctx *context.Context) {
|
||||
// container registry requires that the "/v2" must be in the root, so the sub-path in AppURL should be removed
|
||||
realmURL := httplib.GuessCurrentHostURL(ctx) + "/v2/token"
|
||||
ctx.Resp.Header().Add("WWW-Authenticate", `Bearer realm="`+realmURL+`",service="container_registry",scope="*"`)
|
||||
apiErrorDefined(ctx, errUnauthorized)
|
||||
}
|
||||
|
||||
// ReqContainerAccess is a middleware which checks the current user valid (real user or ghost if anonymous access is enabled)
|
||||
func ReqContainerAccess(ctx *context.Context) {
|
||||
if ctx.Doer == nil || (setting.Service.RequireSignInViewStrict && ctx.Doer.IsGhost()) {
|
||||
apiUnauthorizedError(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyImageName is a middleware which checks if the image name is allowed
|
||||
func VerifyImageName(ctx *context.Context) {
|
||||
if !globalVars().imageNamePattern.MatchString(ctx.PathParam("image")) {
|
||||
apiErrorDefined(ctx, errNameInvalid)
|
||||
}
|
||||
}
|
||||
|
||||
// DetermineSupport is used to test if the registry supports OCI
|
||||
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#determining-support
|
||||
func DetermineSupport(ctx *context.Context) {
|
||||
setResponseHeaders(ctx.Resp, &containerHeaders{
|
||||
Status: http.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// Authenticate creates a token for the current user
|
||||
// If the current user is anonymous, the ghost user is used unless RequireSignInView is enabled.
|
||||
func Authenticate(ctx *context.Context) {
|
||||
u := ctx.Doer
|
||||
packageScope := auth_service.GetAccessScope(ctx.Data)
|
||||
if u == nil {
|
||||
if setting.Service.RequireSignInViewStrict {
|
||||
apiUnauthorizedError(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
u = user_model.NewGhostUser()
|
||||
} else {
|
||||
if has, err := packageScope.HasAnyScope(
|
||||
auth_model.AccessTokenScopeReadPackage,
|
||||
auth_model.AccessTokenScopeWritePackage,
|
||||
auth_model.AccessTokenScopeAll,
|
||||
); !has {
|
||||
if err != nil {
|
||||
log.Error("Error checking access scope: %v", err)
|
||||
}
|
||||
apiUnauthorizedError(ctx)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
token, err := packages_service.CreateAuthorizationToken(u, packageScope)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, map[string]string{
|
||||
"token": token,
|
||||
})
|
||||
}
|
||||
|
||||
// https://distribution.github.io/distribution/spec/auth/oauth/
|
||||
func AuthenticateNotImplemented(ctx *context.Context) {
|
||||
// This optional endpoint can be used to authenticate a client.
|
||||
// It must implement the specification described in:
|
||||
// https://datatracker.ietf.org/doc/html/rfc6749
|
||||
// https://distribution.github.io/distribution/spec/auth/oauth/
|
||||
// Purpose of this stub is to respond with 404 Not Found instead of 405 Method Not Allowed.
|
||||
|
||||
ctx.Status(http.StatusNotFound)
|
||||
}
|
||||
|
||||
// https://docs.docker.com/registry/spec/api/#listing-repositories
|
||||
func GetRepositoryList(ctx *context.Context) {
|
||||
n := ctx.FormInt("n")
|
||||
if n <= 0 || n > 100 {
|
||||
n = 100
|
||||
}
|
||||
last := ctx.FormTrim("last")
|
||||
|
||||
repositories, err := container_model.GetRepositories(ctx, ctx.Doer, n, last)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
type RepositoryList struct {
|
||||
Repositories []string `json:"repositories"`
|
||||
}
|
||||
|
||||
if len(repositories) == n {
|
||||
v := url.Values{}
|
||||
if n > 0 {
|
||||
v.Add("n", strconv.Itoa(n)) // FIXME: "n" can't be zero here, the logic is inconsistent with GetTagsList
|
||||
}
|
||||
v.Add("last", repositories[len(repositories)-1])
|
||||
|
||||
ctx.Resp.Header().Set("Link", fmt.Sprintf(`</v2/_catalog?%s>; rel="next"`, v.Encode()))
|
||||
}
|
||||
|
||||
jsonResponse(ctx, http.StatusOK, RepositoryList{
|
||||
Repositories: repositories,
|
||||
})
|
||||
}
|
||||
|
||||
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#mounting-a-blob-from-another-repository
|
||||
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#single-post
|
||||
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks
|
||||
func PostBlobsUploads(ctx *context.Context) {
|
||||
image := ctx.PathParam("image")
|
||||
|
||||
mount := ctx.FormTrim("mount")
|
||||
from := ctx.FormTrim("from")
|
||||
if mount != "" {
|
||||
blob, _ := workaroundGetContainerBlob(ctx, &container_model.BlobSearchOptions{
|
||||
Repository: from,
|
||||
Digest: mount,
|
||||
})
|
||||
if blob != nil {
|
||||
accessible, err := packages_model.IsBlobAccessibleForUser(ctx, blob.Blob.ID, ctx.Doer)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if accessible {
|
||||
if err := mountBlob(ctx, &packages_service.PackageInfo{Owner: ctx.Package.Owner, Name: image}, blob.Blob); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
setResponseHeaders(ctx.Resp, &containerHeaders{
|
||||
Location: fmt.Sprintf("/v2/%s/%s/blobs/%s", ctx.Package.Owner.LowerName, image, mount),
|
||||
ContentDigest: mount,
|
||||
Status: http.StatusCreated,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
digest := ctx.FormTrim("digest")
|
||||
if digest != "" {
|
||||
buf, err := packages_module.CreateHashedBufferFromReader(ctx.Req.Body)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer buf.Close()
|
||||
|
||||
if digest != digestFromHashSummer(buf) {
|
||||
apiErrorDefined(ctx, errDigestInvalid)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := saveAsPackageBlob(ctx,
|
||||
buf,
|
||||
&packages_service.PackageCreationInfo{
|
||||
PackageInfo: packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
Name: image,
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
},
|
||||
); err != nil {
|
||||
switch err {
|
||||
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
default:
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setResponseHeaders(ctx.Resp, &containerHeaders{
|
||||
Location: fmt.Sprintf("/v2/%s/%s/blobs/%s", ctx.Package.Owner.LowerName, image, digest),
|
||||
ContentDigest: digest,
|
||||
Status: http.StatusCreated,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
upload, err := packages_model.CreateBlobUpload(ctx)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
setResponseHeaders(ctx.Resp, &containerHeaders{
|
||||
Location: fmt.Sprintf("/v2/%s/%s/blobs/uploads/%s", ctx.Package.Owner.LowerName, image, upload.ID),
|
||||
UploadUUID: upload.ID,
|
||||
Status: http.StatusAccepted,
|
||||
})
|
||||
}
|
||||
|
||||
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks
|
||||
func GetBlobsUpload(ctx *context.Context) {
|
||||
image := ctx.PathParam("image")
|
||||
uuid := ctx.PathParam("uuid")
|
||||
|
||||
upload, err := packages_model.GetBlobUploadByID(ctx, uuid)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageBlobUploadNotExist) {
|
||||
apiErrorDefined(ctx, errBlobUploadUnknown)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// FIXME: undefined behavior when the uploaded content is empty: https://github.com/opencontainers/distribution-spec/issues/578
|
||||
respHeaders := &containerHeaders{
|
||||
Location: fmt.Sprintf("/v2/%s/%s/blobs/uploads/%s", ctx.Package.Owner.LowerName, image, upload.ID),
|
||||
UploadUUID: upload.ID,
|
||||
Status: http.StatusNoContent,
|
||||
}
|
||||
if upload.BytesReceived > 0 {
|
||||
respHeaders.Range = fmt.Sprintf("0-%d", upload.BytesReceived-1)
|
||||
}
|
||||
setResponseHeaders(ctx.Resp, respHeaders)
|
||||
}
|
||||
|
||||
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#single-post
|
||||
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks
|
||||
func PatchBlobsUpload(ctx *context.Context) {
|
||||
image := ctx.PathParam("image")
|
||||
|
||||
uploader, err := container_service.NewBlobUploader(ctx, ctx.PathParam("uuid"))
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageBlobUploadNotExist) {
|
||||
apiErrorDefined(ctx, errBlobUploadUnknown)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
defer uploader.Close()
|
||||
|
||||
contentRange := ctx.Req.Header.Get("Content-Range")
|
||||
if contentRange != "" {
|
||||
start, end := 0, 0
|
||||
if _, err := fmt.Sscanf(contentRange, "%d-%d", &start, &end); err != nil {
|
||||
apiErrorDefined(ctx, errBlobUploadInvalid)
|
||||
return
|
||||
}
|
||||
|
||||
if int64(start) != uploader.Size() {
|
||||
apiErrorDefined(ctx, errBlobUploadInvalid.WithStatusCode(http.StatusRequestedRangeNotSatisfiable))
|
||||
return
|
||||
}
|
||||
} else if uploader.Size() != 0 {
|
||||
apiErrorDefined(ctx, errBlobUploadInvalid.WithMessage("Stream uploads after first write are not allowed"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := uploader.Append(ctx, ctx.Req.Body); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
respHeaders := &containerHeaders{
|
||||
Location: fmt.Sprintf("/v2/%s/%s/blobs/uploads/%s", ctx.Package.Owner.LowerName, image, uploader.ID),
|
||||
UploadUUID: uploader.ID,
|
||||
Status: http.StatusAccepted,
|
||||
}
|
||||
if uploader.Size() > 0 {
|
||||
respHeaders.Range = fmt.Sprintf("0-%d", uploader.Size()-1)
|
||||
}
|
||||
setResponseHeaders(ctx.Resp, respHeaders)
|
||||
}
|
||||
|
||||
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks
|
||||
func PutBlobsUpload(ctx *context.Context) {
|
||||
image := ctx.PathParam("image")
|
||||
|
||||
digest := ctx.FormTrim("digest")
|
||||
if digest == "" {
|
||||
apiErrorDefined(ctx, errDigestInvalid)
|
||||
return
|
||||
}
|
||||
|
||||
uploader, err := container_service.NewBlobUploader(ctx, ctx.PathParam("uuid"))
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageBlobUploadNotExist) {
|
||||
apiErrorDefined(ctx, errBlobUploadUnknown)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
defer uploader.Close()
|
||||
|
||||
if ctx.Req.Body != nil {
|
||||
if err := uploader.Append(ctx, ctx.Req.Body); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if digest != digestFromHashSummer(uploader) {
|
||||
apiErrorDefined(ctx, errDigestInvalid)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := saveAsPackageBlob(ctx,
|
||||
uploader,
|
||||
&packages_service.PackageCreationInfo{
|
||||
PackageInfo: packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
Name: image,
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
},
|
||||
); err != nil {
|
||||
switch err {
|
||||
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
default:
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Some SDK (e.g.: minio) will close the Reader if it is also a Closer after "uploading".
|
||||
// And we don't need to wrap the reader to anything else because the SDK will benefit from other interfaces like Seeker.
|
||||
// It's safe to call Close twice, so ignore the error.
|
||||
_ = uploader.Close()
|
||||
|
||||
if err := container_service.RemoveBlobUploadByID(ctx, uploader.ID); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
setResponseHeaders(ctx.Resp, &containerHeaders{
|
||||
Location: fmt.Sprintf("/v2/%s/%s/blobs/%s", ctx.Package.Owner.LowerName, image, digest),
|
||||
ContentDigest: digest,
|
||||
Status: http.StatusCreated,
|
||||
})
|
||||
}
|
||||
|
||||
// https://docs.docker.com/registry/spec/api/#delete-blob-upload
|
||||
func DeleteBlobsUpload(ctx *context.Context) {
|
||||
uuid := ctx.PathParam("uuid")
|
||||
|
||||
_, err := packages_model.GetBlobUploadByID(ctx, uuid)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageBlobUploadNotExist) {
|
||||
apiErrorDefined(ctx, errBlobUploadUnknown)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := container_service.RemoveBlobUploadByID(ctx, uuid); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
setResponseHeaders(ctx.Resp, &containerHeaders{
|
||||
Status: http.StatusNoContent,
|
||||
})
|
||||
}
|
||||
|
||||
func getBlobFromContext(ctx *context.Context) (*packages_model.PackageFileDescriptor, error) {
|
||||
d := digest.Digest(ctx.PathParam("digest"))
|
||||
if d.Validate() != nil {
|
||||
return nil, container_model.ErrContainerBlobNotExist
|
||||
}
|
||||
|
||||
return workaroundGetContainerBlob(ctx, &container_model.BlobSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Image: ctx.PathParam("image"),
|
||||
Digest: string(d),
|
||||
})
|
||||
}
|
||||
|
||||
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#checking-if-content-exists-in-the-registry
|
||||
func HeadBlob(ctx *context.Context) {
|
||||
blob, err := getBlobFromContext(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, container_model.ErrContainerBlobNotExist) {
|
||||
apiErrorDefined(ctx, errBlobUnknown)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setResponseHeaders(ctx.Resp, &containerHeaders{
|
||||
ContentDigest: blob.Properties.GetByName(container_module.PropertyDigest),
|
||||
ContentLength: optional.Some(blob.Blob.Size),
|
||||
Status: http.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pulling-blobs
|
||||
func GetBlob(ctx *context.Context) {
|
||||
blob, err := getBlobFromContext(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, container_model.ErrContainerBlobNotExist) {
|
||||
apiErrorDefined(ctx, errBlobUnknown)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
serveBlob(ctx, blob)
|
||||
}
|
||||
|
||||
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#deleting-blobs
|
||||
func DeleteBlob(ctx *context.Context) {
|
||||
d := digest.Digest(ctx.PathParam("digest"))
|
||||
if d.Validate() != nil {
|
||||
apiErrorDefined(ctx, errBlobUnknown)
|
||||
return
|
||||
}
|
||||
|
||||
if err := deleteBlob(ctx, ctx.Package.Owner.ID, ctx.PathParam("image"), d); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
setResponseHeaders(ctx.Resp, &containerHeaders{
|
||||
Status: http.StatusAccepted,
|
||||
})
|
||||
}
|
||||
|
||||
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-manifests
|
||||
func PutManifest(ctx *context.Context) {
|
||||
reference := ctx.PathParam("reference")
|
||||
|
||||
mci := &manifestCreationInfo{
|
||||
MediaType: ctx.Req.Header.Get("Content-Type"),
|
||||
Owner: ctx.Package.Owner,
|
||||
Creator: ctx.Doer,
|
||||
Image: ctx.PathParam("image"),
|
||||
Reference: reference,
|
||||
IsTagged: digest.Digest(reference).Validate() != nil,
|
||||
}
|
||||
|
||||
if mci.IsTagged && !globalVars().referencePattern.MatchString(reference) {
|
||||
apiErrorDefined(ctx, errManifestInvalid.WithMessage("Tag is invalid"))
|
||||
return
|
||||
}
|
||||
|
||||
maxSize := maxManifestSize + 1
|
||||
buf, err := packages_module.CreateHashedBufferFromReaderWithSize(&io.LimitedReader{R: ctx.Req.Body, N: int64(maxSize)}, maxSize)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer buf.Close()
|
||||
|
||||
if buf.Size() > maxManifestSize {
|
||||
apiErrorDefined(ctx, errManifestInvalid.WithMessage("Manifest exceeds maximum size").WithStatusCode(http.StatusRequestEntityTooLarge))
|
||||
return
|
||||
}
|
||||
|
||||
digest, err := processManifest(ctx, mci, buf)
|
||||
if err != nil {
|
||||
var namedError *namedError
|
||||
if errors.As(err, &namedError) {
|
||||
apiErrorDefined(ctx, namedError)
|
||||
} else if errors.Is(err, container_model.ErrContainerBlobNotExist) {
|
||||
apiErrorDefined(ctx, errBlobUnknown)
|
||||
} else {
|
||||
switch err {
|
||||
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
default:
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setResponseHeaders(ctx.Resp, &containerHeaders{
|
||||
Location: fmt.Sprintf("/v2/%s/%s/manifests/%s", ctx.Package.Owner.LowerName, mci.Image, reference),
|
||||
ContentDigest: digest,
|
||||
Status: http.StatusCreated,
|
||||
})
|
||||
}
|
||||
|
||||
func getBlobSearchOptionsFromContext(ctx *context.Context) (*container_model.BlobSearchOptions, error) {
|
||||
opts := &container_model.BlobSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Image: ctx.PathParam("image"),
|
||||
IsManifest: true,
|
||||
}
|
||||
|
||||
reference := ctx.PathParam("reference")
|
||||
if d := digest.Digest(reference); d.Validate() == nil {
|
||||
opts.Digest = string(d)
|
||||
} else if globalVars().referencePattern.MatchString(reference) {
|
||||
opts.Tag = reference
|
||||
opts.OnlyLead = true
|
||||
} else {
|
||||
return nil, container_model.ErrContainerBlobNotExist
|
||||
}
|
||||
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func getManifestFromContext(ctx *context.Context) (*packages_model.PackageFileDescriptor, error) {
|
||||
opts, err := getBlobSearchOptionsFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return workaroundGetContainerBlob(ctx, opts)
|
||||
}
|
||||
|
||||
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#checking-if-content-exists-in-the-registry
|
||||
func HeadManifest(ctx *context.Context) {
|
||||
manifest, err := getManifestFromContext(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, container_model.ErrContainerBlobNotExist) {
|
||||
apiErrorDefined(ctx, errManifestUnknown)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setResponseHeaders(ctx.Resp, &containerHeaders{
|
||||
ContentDigest: manifest.Properties.GetByName(container_module.PropertyDigest),
|
||||
ContentType: manifest.Properties.GetByName(container_module.PropertyMediaType),
|
||||
ContentLength: optional.Some(manifest.Blob.Size),
|
||||
Status: http.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pulling-manifests
|
||||
func GetManifest(ctx *context.Context) {
|
||||
manifest, err := getManifestFromContext(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, container_model.ErrContainerBlobNotExist) {
|
||||
apiErrorDefined(ctx, errManifestUnknown)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
serveBlob(ctx, manifest)
|
||||
}
|
||||
|
||||
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#deleting-tags
|
||||
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#deleting-manifests
|
||||
func DeleteManifest(ctx *context.Context) {
|
||||
opts, err := getBlobSearchOptionsFromContext(ctx)
|
||||
if err != nil {
|
||||
apiErrorDefined(ctx, errManifestUnknown)
|
||||
return
|
||||
}
|
||||
|
||||
pvs, err := container_model.GetManifestVersions(ctx, opts)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(pvs) == 0 {
|
||||
apiErrorDefined(ctx, errManifestUnknown)
|
||||
return
|
||||
}
|
||||
|
||||
for _, pv := range pvs {
|
||||
if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setResponseHeaders(ctx.Resp, &containerHeaders{
|
||||
Status: http.StatusAccepted,
|
||||
})
|
||||
}
|
||||
|
||||
func serveBlob(ctx *context.Context, pfd *packages_model.PackageFileDescriptor) {
|
||||
serveDirectReqParams := make(url.Values)
|
||||
serveDirectReqParams.Set("response-content-type", pfd.Properties.GetByName(container_module.PropertyMediaType))
|
||||
s, u, _, err := packages_service.OpenBlobForDownload(ctx, pfd.File, pfd.Blob, ctx.Req.Method, serveDirectReqParams)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
headers := &containerHeaders{
|
||||
ContentDigest: pfd.Properties.GetByName(container_module.PropertyDigest),
|
||||
ContentType: pfd.Properties.GetByName(container_module.PropertyMediaType),
|
||||
ContentLength: optional.Some(pfd.Blob.Size),
|
||||
Status: http.StatusOK,
|
||||
}
|
||||
|
||||
if u != nil {
|
||||
headers.Status = http.StatusTemporaryRedirect
|
||||
headers.Location = u.String()
|
||||
headers.ContentLength = optional.None[int64]() // do not set Content-Length for redirect responses
|
||||
setResponseHeaders(ctx.Resp, headers)
|
||||
return
|
||||
}
|
||||
|
||||
defer s.Close()
|
||||
|
||||
setResponseHeaders(ctx.Resp, headers)
|
||||
_, _ = io.Copy(ctx.Resp, s)
|
||||
}
|
||||
|
||||
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#content-discovery
|
||||
func GetTagsList(ctx *context.Context) {
|
||||
image := ctx.PathParam("image")
|
||||
|
||||
if _, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeContainer, image); err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiErrorDefined(ctx, errNameUnknown)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
n := -1
|
||||
if ctx.FormTrim("n") != "" {
|
||||
n = ctx.FormInt("n")
|
||||
}
|
||||
last := ctx.FormTrim("last")
|
||||
|
||||
tags, err := container_model.GetImageTags(ctx, ctx.Package.Owner.ID, image, n, last)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
type TagList struct {
|
||||
Name string `json:"name"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
if len(tags) > 0 {
|
||||
v := url.Values{}
|
||||
if n > 0 {
|
||||
v.Add("n", strconv.Itoa(n))
|
||||
}
|
||||
v.Add("last", tags[len(tags)-1])
|
||||
|
||||
ctx.Resp.Header().Set("Link", fmt.Sprintf(`</v2/%s/%s/tags/list?%s>; rel="next"`, ctx.Package.Owner.LowerName, image, v.Encode()))
|
||||
}
|
||||
|
||||
jsonResponse(ctx, http.StatusOK, TagList{
|
||||
Name: strings.ToLower(ctx.Package.Owner.LowerName + "/" + image),
|
||||
Tags: tags,
|
||||
})
|
||||
}
|
||||
|
||||
// FIXME: Workaround to be removed in v1.20.
|
||||
// Update maybe we should never really remote it, as long as there is legacy data?
|
||||
// https://github.com/go-gitea/gitea/issues/19586
|
||||
func workaroundGetContainerBlob(ctx *context.Context, opts *container_model.BlobSearchOptions) (*packages_model.PackageFileDescriptor, error) {
|
||||
blob, err := container_model.GetContainerBlob(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = packages_module.NewContentStore().Has(packages_module.BlobHash256Key(blob.Blob.HashSHA256))
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) || errors.Is(err, os.ErrNotExist) {
|
||||
log.Debug("Package registry inconsistent: blob %s does not exist on file system", blob.Blob.HashSHA256)
|
||||
return nil, container_model.ErrContainerBlobNotExist
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return blob, nil
|
||||
}
|
||||
52
routers/api/packages/container/errors.go
Normal file
52
routers/api/packages/container/errors.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#error-codes
|
||||
var (
|
||||
errBlobUnknown = &namedError{Code: "BLOB_UNKNOWN", StatusCode: http.StatusNotFound}
|
||||
errBlobUploadInvalid = &namedError{Code: "BLOB_UPLOAD_INVALID", StatusCode: http.StatusBadRequest}
|
||||
errBlobUploadUnknown = &namedError{Code: "BLOB_UPLOAD_UNKNOWN", StatusCode: http.StatusNotFound}
|
||||
errDigestInvalid = &namedError{Code: "DIGEST_INVALID", StatusCode: http.StatusBadRequest}
|
||||
errManifestBlobUnknown = &namedError{Code: "MANIFEST_BLOB_UNKNOWN", StatusCode: http.StatusNotFound}
|
||||
errManifestInvalid = &namedError{Code: "MANIFEST_INVALID", StatusCode: http.StatusBadRequest}
|
||||
errManifestUnknown = &namedError{Code: "MANIFEST_UNKNOWN", StatusCode: http.StatusNotFound}
|
||||
errNameInvalid = &namedError{Code: "NAME_INVALID", StatusCode: http.StatusBadRequest}
|
||||
errNameUnknown = &namedError{Code: "NAME_UNKNOWN", StatusCode: http.StatusNotFound}
|
||||
errSizeInvalid = &namedError{Code: "SIZE_INVALID", StatusCode: http.StatusBadRequest}
|
||||
errUnauthorized = &namedError{Code: "UNAUTHORIZED", StatusCode: http.StatusUnauthorized}
|
||||
errUnsupported = &namedError{Code: "UNSUPPORTED", StatusCode: http.StatusNotImplemented}
|
||||
)
|
||||
|
||||
type namedError struct {
|
||||
Code string
|
||||
StatusCode int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *namedError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// WithMessage creates a new instance of the error with a different message
|
||||
func (e *namedError) WithMessage(message string) *namedError {
|
||||
return &namedError{
|
||||
Code: e.Code,
|
||||
StatusCode: e.StatusCode,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
// WithStatusCode creates a new instance of the error with a different status code
|
||||
func (e *namedError) WithStatusCode(statusCode int) *namedError {
|
||||
return &namedError{
|
||||
Code: e.Code,
|
||||
StatusCode: statusCode,
|
||||
Message: e.Message,
|
||||
}
|
||||
}
|
||||
435
routers/api/packages/container/manifest.go
Normal file
435
routers/api/packages/container/manifest.go
Normal file
@@ -0,0 +1,435 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
container_model "code.gitea.io/gitea/models/packages/container"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/globallock"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
packages_module "code.gitea.io/gitea/modules/packages"
|
||||
container_module "code.gitea.io/gitea/modules/packages/container"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
notify_service "code.gitea.io/gitea/services/notify"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
container_service "code.gitea.io/gitea/services/packages/container"
|
||||
|
||||
"github.com/opencontainers/go-digest"
|
||||
oci "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
)
|
||||
|
||||
// manifestCreationInfo describes a manifest to create
|
||||
type manifestCreationInfo struct {
|
||||
MediaType string
|
||||
Owner *user_model.User
|
||||
Creator *user_model.User
|
||||
Image string
|
||||
Reference string
|
||||
IsTagged bool
|
||||
Properties map[string]string
|
||||
}
|
||||
|
||||
func processManifest(ctx context.Context, mci *manifestCreationInfo, buf *packages_module.HashedBuffer) (string, error) {
|
||||
var index oci.Index
|
||||
if err := json.NewDecoder(buf).Decode(&index); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if index.SchemaVersion != 2 {
|
||||
return "", errUnsupported.WithMessage("Schema version is not supported")
|
||||
}
|
||||
if _, err := buf.Seek(0, io.SeekStart); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !container_module.IsMediaTypeValid(mci.MediaType) {
|
||||
mci.MediaType = index.MediaType
|
||||
if !container_module.IsMediaTypeValid(mci.MediaType) {
|
||||
return "", errManifestInvalid.WithMessage("MediaType not recognized")
|
||||
}
|
||||
}
|
||||
|
||||
// .../container/manifest.go:453:createManifestBlob() [E] Error inserting package blob: Error 1062 (23000): Duplicate entry '..........' for key 'package_blob.UQE_package_blob_md5'
|
||||
releaser, err := globallock.Lock(ctx, containerGlobalLockKey(mci.Owner.ID, mci.Image, "manifest"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer releaser()
|
||||
|
||||
if container_module.IsMediaTypeImageManifest(mci.MediaType) {
|
||||
return processOciImageManifest(ctx, mci, buf)
|
||||
} else if container_module.IsMediaTypeImageIndex(mci.MediaType) {
|
||||
return processOciImageIndex(ctx, mci, buf)
|
||||
}
|
||||
return "", errManifestInvalid
|
||||
}
|
||||
|
||||
type processManifestTxRet struct {
|
||||
pv *packages_model.PackageVersion
|
||||
pb *packages_model.PackageBlob
|
||||
created bool
|
||||
digest string
|
||||
}
|
||||
|
||||
func handleCreateManifestResult(ctx context.Context, err error, mci *manifestCreationInfo, contentStore *packages_module.ContentStore, txRet *processManifestTxRet) (string, error) {
|
||||
if err != nil && txRet.created && txRet.pb != nil {
|
||||
if err := contentStore.Delete(packages_module.BlobHash256Key(txRet.pb.HashSHA256)); err != nil {
|
||||
log.Error("Error deleting package blob from content store: %v", err)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
pd, err := packages_model.GetPackageDescriptor(ctx, txRet.pv)
|
||||
if err != nil {
|
||||
log.Error("Error getting package descriptor: %v", err) // ignore this error
|
||||
} else {
|
||||
notify_service.PackageCreate(ctx, mci.Creator, pd)
|
||||
}
|
||||
return txRet.digest, nil
|
||||
}
|
||||
|
||||
func processOciImageManifest(ctx context.Context, mci *manifestCreationInfo, buf *packages_module.HashedBuffer) (manifestDigest string, errRet error) {
|
||||
manifest, configDescriptor, metadata, err := container_service.ParseManifestMetadata(ctx, buf, mci.Owner.ID, mci.Image)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err = buf.Seek(0, io.SeekStart); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
contentStore := packages_module.NewContentStore()
|
||||
var txRet processManifestTxRet
|
||||
err = db.WithTx(ctx, func(ctx context.Context) (err error) {
|
||||
blobReferences := make([]*blobReference, 0, 1+len(manifest.Layers))
|
||||
blobReferences = append(blobReferences, &blobReference{
|
||||
Digest: manifest.Config.Digest,
|
||||
MediaType: manifest.Config.MediaType,
|
||||
File: configDescriptor,
|
||||
ExpectedSize: manifest.Config.Size,
|
||||
})
|
||||
|
||||
for _, layer := range manifest.Layers {
|
||||
pfd, err := container_model.GetContainerBlob(ctx, &container_model.BlobSearchOptions{
|
||||
OwnerID: mci.Owner.ID,
|
||||
Image: mci.Image,
|
||||
Digest: string(layer.Digest),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
blobReferences = append(blobReferences, &blobReference{
|
||||
Digest: layer.Digest,
|
||||
MediaType: layer.MediaType,
|
||||
File: pfd,
|
||||
ExpectedSize: layer.Size,
|
||||
})
|
||||
}
|
||||
|
||||
pv, err := createPackageAndVersion(ctx, mci, metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
uploadVersion, err := packages_model.GetInternalVersionByNameAndVersion(ctx, mci.Owner.ID, packages_model.TypeContainer, mci.Image, container_module.UploadVersion)
|
||||
if err != nil && !errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, ref := range blobReferences {
|
||||
if _, err = createFileFromBlobReference(ctx, pv, uploadVersion, ref); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
txRet.pv = pv
|
||||
txRet.pb, txRet.created, txRet.digest, err = createManifestBlob(ctx, contentStore, mci, pv, buf)
|
||||
return err
|
||||
})
|
||||
|
||||
return handleCreateManifestResult(ctx, err, mci, contentStore, &txRet)
|
||||
}
|
||||
|
||||
func processOciImageIndex(ctx context.Context, mci *manifestCreationInfo, buf *packages_module.HashedBuffer) (manifestDigest string, errRet error) {
|
||||
var index oci.Index
|
||||
if err := json.NewDecoder(buf).Decode(&index); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := buf.Seek(0, io.SeekStart); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
contentStore := packages_module.NewContentStore()
|
||||
var txRet processManifestTxRet
|
||||
err := db.WithTx(ctx, func(ctx context.Context) (err error) {
|
||||
metadata := &container_module.Metadata{
|
||||
Type: container_module.TypeOCI,
|
||||
Manifests: make([]*container_module.Manifest, 0, len(index.Manifests)),
|
||||
}
|
||||
|
||||
for _, manifest := range index.Manifests {
|
||||
if !container_module.IsMediaTypeImageManifest(manifest.MediaType) {
|
||||
return errManifestInvalid
|
||||
}
|
||||
|
||||
platform := container_module.DefaultPlatform
|
||||
if manifest.Platform != nil {
|
||||
platform = fmt.Sprintf("%s/%s", manifest.Platform.OS, manifest.Platform.Architecture)
|
||||
if manifest.Platform.Variant != "" {
|
||||
platform = fmt.Sprintf("%s/%s", platform, manifest.Platform.Variant)
|
||||
}
|
||||
}
|
||||
|
||||
pfd, err := container_model.GetContainerBlob(ctx, &container_model.BlobSearchOptions{
|
||||
OwnerID: mci.Owner.ID,
|
||||
Image: mci.Image,
|
||||
Digest: string(manifest.Digest),
|
||||
IsManifest: true,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, container_model.ErrContainerBlobNotExist) {
|
||||
return errManifestBlobUnknown
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
size, err := packages_model.CalculateFileSize(ctx, &packages_model.PackageFileSearchOptions{
|
||||
VersionID: pfd.File.VersionID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
metadata.Manifests = append(metadata.Manifests, &container_module.Manifest{
|
||||
Platform: platform,
|
||||
Digest: string(manifest.Digest),
|
||||
Size: size,
|
||||
})
|
||||
}
|
||||
|
||||
pv, err := createPackageAndVersion(ctx, mci, metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
txRet.pv = pv
|
||||
txRet.pb, txRet.created, txRet.digest, err = createManifestBlob(ctx, contentStore, mci, pv, buf)
|
||||
return err
|
||||
})
|
||||
|
||||
return handleCreateManifestResult(ctx, err, mci, contentStore, &txRet)
|
||||
}
|
||||
|
||||
func createPackageAndVersion(ctx context.Context, mci *manifestCreationInfo, metadata *container_module.Metadata) (*packages_model.PackageVersion, error) {
|
||||
created := true
|
||||
p := &packages_model.Package{
|
||||
OwnerID: mci.Owner.ID,
|
||||
Type: packages_model.TypeContainer,
|
||||
Name: strings.ToLower(mci.Image),
|
||||
LowerName: strings.ToLower(mci.Image),
|
||||
}
|
||||
var err error
|
||||
if p, err = packages_model.TryInsertPackage(ctx, p); err != nil {
|
||||
if !errors.Is(err, packages_model.ErrDuplicatePackage) {
|
||||
log.Error("Error inserting package: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
created = false
|
||||
}
|
||||
|
||||
if created {
|
||||
if _, err := packages_model.InsertProperty(ctx, packages_model.PropertyTypePackage, p.ID, container_module.PropertyRepository, strings.ToLower(mci.Owner.LowerName+"/"+mci.Image)); err != nil {
|
||||
log.Error("Error setting package property: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
metadata.IsTagged = mci.IsTagged
|
||||
|
||||
metadataJSON, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_pv := &packages_model.PackageVersion{
|
||||
PackageID: p.ID,
|
||||
CreatorID: mci.Creator.ID,
|
||||
Version: strings.ToLower(mci.Reference),
|
||||
LowerVersion: strings.ToLower(mci.Reference),
|
||||
MetadataJSON: string(metadataJSON),
|
||||
}
|
||||
pv, err := packages_model.GetOrInsertVersion(ctx, _pv)
|
||||
if err != nil {
|
||||
if !errors.Is(err, packages_model.ErrDuplicatePackageVersion) {
|
||||
log.Error("Error inserting package: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if container_module.IsMediaTypeImageIndex(mci.MediaType) {
|
||||
if pv.CreatedUnix.AsTime().Before(time.Now().Add(-24 * time.Hour)) {
|
||||
if err = packages_service.DeletePackageVersionAndReferences(ctx, pv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// keep download count on overwriting
|
||||
_pv.DownloadCount = pv.DownloadCount
|
||||
if pv, err = packages_model.GetOrInsertVersion(ctx, _pv); err != nil {
|
||||
if !errors.Is(err, packages_model.ErrDuplicatePackageVersion) {
|
||||
log.Error("Error inserting package: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
err = packages_model.UpdateVersion(ctx, &packages_model.PackageVersion{ID: pv.ID, MetadataJSON: _pv.MetadataJSON})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := packages_service.CheckCountQuotaExceeded(ctx, mci.Creator, mci.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if mci.IsTagged {
|
||||
if err = packages_model.InsertOrUpdateProperty(ctx, packages_model.PropertyTypeVersion, pv.ID, container_module.PropertyManifestTagged, ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err = packages_model.DeletePropertiesByName(ctx, packages_model.PropertyTypeVersion, pv.ID, container_module.PropertyManifestTagged); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err = packages_model.DeletePropertiesByName(ctx, packages_model.PropertyTypeVersion, pv.ID, container_module.PropertyManifestReference); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, manifest := range metadata.Manifests {
|
||||
if _, err = packages_model.InsertProperty(ctx, packages_model.PropertyTypeVersion, pv.ID, container_module.PropertyManifestReference, manifest.Digest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return pv, nil
|
||||
}
|
||||
|
||||
type blobReference struct {
|
||||
Digest digest.Digest
|
||||
MediaType string
|
||||
Name string
|
||||
File *packages_model.PackageFileDescriptor
|
||||
ExpectedSize int64
|
||||
IsLead bool
|
||||
}
|
||||
|
||||
func createFileFromBlobReference(ctx context.Context, pv, uploadVersion *packages_model.PackageVersion, ref *blobReference) (*packages_model.PackageFile, error) {
|
||||
if ref.File.Blob.Size != ref.ExpectedSize {
|
||||
return nil, errSizeInvalid
|
||||
}
|
||||
|
||||
if ref.Name == "" {
|
||||
ref.Name = strings.ToLower("sha256_" + ref.File.Blob.HashSHA256)
|
||||
}
|
||||
|
||||
pf := &packages_model.PackageFile{
|
||||
VersionID: pv.ID,
|
||||
BlobID: ref.File.Blob.ID,
|
||||
Name: ref.Name,
|
||||
LowerName: ref.Name,
|
||||
CompositeKey: string(ref.Digest),
|
||||
IsLead: ref.IsLead,
|
||||
}
|
||||
var err error
|
||||
if pf, err = packages_model.TryInsertFile(ctx, pf); err != nil {
|
||||
if errors.Is(err, packages_model.ErrDuplicatePackageFile) {
|
||||
// Skip this blob because the manifest contains the same filesystem layer multiple times.
|
||||
return pf, nil
|
||||
}
|
||||
log.Error("Error inserting package file: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
props := map[string]string{
|
||||
container_module.PropertyMediaType: ref.MediaType,
|
||||
container_module.PropertyDigest: string(ref.Digest),
|
||||
}
|
||||
for name, value := range props {
|
||||
if _, err := packages_model.InsertProperty(ctx, packages_model.PropertyTypeFile, pf.ID, name, value); err != nil {
|
||||
log.Error("Error setting package file property: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the ref file (old file) from the blob upload version
|
||||
if uploadVersion != nil && ref.File.File != nil && uploadVersion.ID == ref.File.File.VersionID {
|
||||
if err := packages_service.DeletePackageFile(ctx, ref.File.File); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return pf, nil
|
||||
}
|
||||
|
||||
func createManifestBlob(ctx context.Context, contentStore *packages_module.ContentStore, mci *manifestCreationInfo, pv *packages_model.PackageVersion, buf *packages_module.HashedBuffer) (_ *packages_model.PackageBlob, created bool, manifestDigest string, _ error) {
|
||||
pb, exists, err := packages_model.GetOrInsertBlob(ctx, packages_service.NewPackageBlob(buf))
|
||||
if err != nil {
|
||||
log.Error("Error inserting package blob: %v", err)
|
||||
return nil, false, "", err
|
||||
}
|
||||
// FIXME: Workaround to be removed in v1.20
|
||||
// https://github.com/go-gitea/gitea/issues/19586
|
||||
if exists {
|
||||
err = contentStore.Has(packages_module.BlobHash256Key(pb.HashSHA256))
|
||||
if err != nil && (errors.Is(err, util.ErrNotExist) || errors.Is(err, os.ErrNotExist)) {
|
||||
log.Debug("Package registry inconsistent: blob %s does not exist on file system", pb.HashSHA256)
|
||||
exists = false
|
||||
}
|
||||
}
|
||||
if !exists {
|
||||
if err := contentStore.Save(packages_module.BlobHash256Key(pb.HashSHA256), buf, buf.Size()); err != nil {
|
||||
log.Error("Error saving package blob in content store: %v", err)
|
||||
return nil, false, "", err
|
||||
}
|
||||
}
|
||||
|
||||
manifestDigest = digestFromHashSummer(buf)
|
||||
pf, err := createFileFromBlobReference(ctx, pv, nil, &blobReference{
|
||||
Digest: digest.Digest(manifestDigest),
|
||||
MediaType: mci.MediaType,
|
||||
Name: container_module.ManifestFilename,
|
||||
File: &packages_model.PackageFileDescriptor{Blob: pb},
|
||||
ExpectedSize: pb.Size,
|
||||
IsLead: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, "", err
|
||||
}
|
||||
|
||||
oldManifestFiles, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{
|
||||
OwnerID: mci.Owner.ID,
|
||||
PackageType: packages_model.TypeContainer,
|
||||
VersionID: pv.ID,
|
||||
Query: container_module.ManifestFilename,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, "", err
|
||||
}
|
||||
for _, oldManifestFile := range oldManifestFiles {
|
||||
if oldManifestFile.ID != pf.ID && oldManifestFile.IsLead {
|
||||
err = packages_model.UpdateFile(ctx, &packages_model.PackageFile{ID: oldManifestFile.ID, IsLead: false}, []string{"is_lead"})
|
||||
if err != nil {
|
||||
return nil, false, "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
return pb, !exists, manifestDigest, err
|
||||
}
|
||||
263
routers/api/packages/cran/cran.go
Normal file
263
routers/api/packages/cran/cran.go
Normal file
@@ -0,0 +1,263 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cran
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
cran_model "code.gitea.io/gitea/models/packages/cran"
|
||||
packages_module "code.gitea.io/gitea/modules/packages"
|
||||
cran_module "code.gitea.io/gitea/modules/packages/cran"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/routers/api/packages/helper"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
ctx.PlainText(status, message)
|
||||
}
|
||||
|
||||
func EnumerateSourcePackages(ctx *context.Context) {
|
||||
enumeratePackages(ctx, ctx.PathParam("format"), &cran_model.SearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
FileType: cran_module.TypeSource,
|
||||
})
|
||||
}
|
||||
|
||||
func EnumerateBinaryPackages(ctx *context.Context) {
|
||||
enumeratePackages(ctx, ctx.PathParam("format"), &cran_model.SearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
FileType: cran_module.TypeBinary,
|
||||
Platform: ctx.PathParam("platform"),
|
||||
RVersion: ctx.PathParam("rversion"),
|
||||
})
|
||||
}
|
||||
|
||||
func enumeratePackages(ctx *context.Context, format string, opts *cran_model.SearchOptions) {
|
||||
if format != "" && format != ".gz" {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
pvs, err := cran_model.SearchLatestVersions(ctx, opts)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pvs) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
var w io.Writer = ctx.Resp
|
||||
|
||||
if format == ".gz" {
|
||||
ctx.Resp.Header().Set("Content-Type", "application/x-gzip")
|
||||
|
||||
gzw := gzip.NewWriter(w)
|
||||
defer gzw.Close()
|
||||
|
||||
w = gzw
|
||||
} else {
|
||||
ctx.Resp.Header().Set("Content-Type", "text/plain;charset=utf-8")
|
||||
}
|
||||
ctx.Resp.WriteHeader(http.StatusOK)
|
||||
|
||||
for i, pd := range pds {
|
||||
if i > 0 {
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
var pfd *packages_model.PackageFileDescriptor
|
||||
for _, d := range pd.Files {
|
||||
if d.Properties.GetByName(cran_module.PropertyType) == opts.FileType &&
|
||||
d.Properties.GetByName(cran_module.PropertyPlatform) == opts.Platform &&
|
||||
d.Properties.GetByName(cran_module.PropertyRVersion) == opts.RVersion {
|
||||
pfd = d
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
metadata := pd.Metadata.(*cran_module.Metadata)
|
||||
|
||||
fmt.Fprintln(w, "Package:", pd.Package.Name)
|
||||
fmt.Fprintln(w, "Version:", pd.Version.Version)
|
||||
if metadata.License != "" {
|
||||
fmt.Fprintln(w, "License:", metadata.License)
|
||||
}
|
||||
if len(metadata.Depends) > 0 {
|
||||
fmt.Fprintln(w, "Depends:", strings.Join(metadata.Depends, ", "))
|
||||
}
|
||||
if len(metadata.Imports) > 0 {
|
||||
fmt.Fprintln(w, "Imports:", strings.Join(metadata.Imports, ", "))
|
||||
}
|
||||
if len(metadata.LinkingTo) > 0 {
|
||||
fmt.Fprintln(w, "LinkingTo:", strings.Join(metadata.LinkingTo, ", "))
|
||||
}
|
||||
if len(metadata.Suggests) > 0 {
|
||||
fmt.Fprintln(w, "Suggests:", strings.Join(metadata.Suggests, ", "))
|
||||
}
|
||||
needsCompilation := "no"
|
||||
if metadata.NeedsCompilation {
|
||||
needsCompilation = "yes"
|
||||
}
|
||||
fmt.Fprintln(w, "NeedsCompilation:", needsCompilation)
|
||||
fmt.Fprintln(w, "MD5sum:", pfd.Blob.HashMD5)
|
||||
}
|
||||
}
|
||||
|
||||
func UploadSourcePackageFile(ctx *context.Context) {
|
||||
uploadPackageFile(
|
||||
ctx,
|
||||
packages_model.EmptyFileKey,
|
||||
map[string]string{
|
||||
cran_module.PropertyType: cran_module.TypeSource,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func UploadBinaryPackageFile(ctx *context.Context) {
|
||||
platform, rversion := ctx.FormTrim("platform"), ctx.FormTrim("rversion")
|
||||
if platform == "" || rversion == "" {
|
||||
apiError(ctx, http.StatusBadRequest, nil)
|
||||
return
|
||||
}
|
||||
|
||||
uploadPackageFile(
|
||||
ctx,
|
||||
platform+"|"+rversion,
|
||||
map[string]string{
|
||||
cran_module.PropertyType: cran_module.TypeBinary,
|
||||
cran_module.PropertyPlatform: platform,
|
||||
cran_module.PropertyRVersion: rversion,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func uploadPackageFile(ctx *context.Context, compositeKey string, properties map[string]string) {
|
||||
upload, needToClose, err := ctx.UploadStream()
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if needToClose {
|
||||
defer upload.Close()
|
||||
}
|
||||
|
||||
buf, err := packages_module.CreateHashedBufferFromReader(upload)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer buf.Close()
|
||||
|
||||
pck, err := cran_module.ParsePackage(buf, buf.Size())
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := buf.Seek(0, io.SeekStart); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
_, _, err = packages_service.CreatePackageOrAddFileToExisting(
|
||||
ctx,
|
||||
&packages_service.PackageCreationInfo{
|
||||
PackageInfo: packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeCran,
|
||||
Name: pck.Name,
|
||||
Version: pck.Version,
|
||||
},
|
||||
SemverCompatible: false,
|
||||
Creator: ctx.Doer,
|
||||
Metadata: pck.Metadata,
|
||||
},
|
||||
&packages_service.PackageFileCreationInfo{
|
||||
PackageFileInfo: packages_service.PackageFileInfo{
|
||||
Filename: fmt.Sprintf("%s_%s%s", pck.Name, pck.Version, pck.FileExtension),
|
||||
CompositeKey: compositeKey,
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Data: buf,
|
||||
IsLead: true,
|
||||
Properties: properties,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case packages_model.ErrDuplicatePackageFile:
|
||||
apiError(ctx, http.StatusConflict, err)
|
||||
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
default:
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusCreated)
|
||||
}
|
||||
|
||||
func DownloadSourcePackageFile(ctx *context.Context) {
|
||||
downloadPackageFile(ctx, &cran_model.SearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
FileType: cran_module.TypeSource,
|
||||
Filename: ctx.PathParam("filename"),
|
||||
})
|
||||
}
|
||||
|
||||
func DownloadBinaryPackageFile(ctx *context.Context) {
|
||||
downloadPackageFile(ctx, &cran_model.SearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
FileType: cran_module.TypeBinary,
|
||||
Platform: ctx.PathParam("platform"),
|
||||
RVersion: ctx.PathParam("rversion"),
|
||||
Filename: ctx.PathParam("filename"),
|
||||
})
|
||||
}
|
||||
|
||||
func downloadPackageFile(ctx *context.Context, opts *cran_model.SearchOptions) {
|
||||
pf, err := cran_model.SearchFile(ctx, opts)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
s, u, _, err := packages_service.OpenFileForDownload(ctx, pf, ctx.Req.Method)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
310
routers/api/packages/debian/debian.go
Normal file
310
routers/api/packages/debian/debian.go
Normal file
@@ -0,0 +1,310 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package debian
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
packages_module "code.gitea.io/gitea/modules/packages"
|
||||
debian_module "code.gitea.io/gitea/modules/packages/debian"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/routers/api/packages/helper"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
notify_service "code.gitea.io/gitea/services/notify"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
debian_service "code.gitea.io/gitea/services/packages/debian"
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
ctx.PlainText(status, message)
|
||||
}
|
||||
|
||||
func GetRepositoryKey(ctx *context.Context) {
|
||||
_, pub, err := debian_service.GetOrCreateKeyPair(ctx, ctx.Package.Owner.ID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.ServeContent(strings.NewReader(pub), &context.ServeHeaderOptions{
|
||||
ContentType: "application/pgp-keys",
|
||||
Filename: "repository.key",
|
||||
})
|
||||
}
|
||||
|
||||
// https://wiki.debian.org/DebianRepository/Format#A.22Release.22_files
|
||||
// https://wiki.debian.org/DebianRepository/Format#A.22Packages.22_Indices
|
||||
func GetRepositoryFile(ctx *context.Context) {
|
||||
pv, err := debian_service.GetOrCreateRepositoryVersion(ctx, ctx.Package.Owner.ID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
key := ctx.PathParam("distribution")
|
||||
|
||||
component := ctx.PathParam("component")
|
||||
architecture := strings.TrimPrefix(ctx.PathParam("architecture"), "binary-")
|
||||
if component != "" && architecture != "" {
|
||||
key += "|" + component + "|" + architecture
|
||||
}
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion(
|
||||
ctx,
|
||||
pv,
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: ctx.PathParam("filename"),
|
||||
CompositeKey: key,
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(err, packages_model.ErrPackageFileNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
|
||||
// https://wiki.debian.org/DebianRepository/Format#indices_acquisition_via_hashsums_.28by-hash.29
|
||||
func GetRepositoryFileByHash(ctx *context.Context) {
|
||||
pv, err := debian_service.GetOrCreateRepositoryVersion(ctx, ctx.Package.Owner.ID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
algorithm := strings.ToLower(ctx.PathParam("algorithm"))
|
||||
if algorithm == "md5sum" {
|
||||
algorithm = "md5"
|
||||
}
|
||||
|
||||
pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{
|
||||
VersionID: pv.ID,
|
||||
Hash: strings.ToLower(ctx.PathParam("hash")),
|
||||
HashAlgorithm: algorithm,
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pfs) != 1 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownload(ctx, pfs[0], ctx.Req.Method)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
|
||||
func UploadPackageFile(ctx *context.Context) {
|
||||
distribution := strings.TrimSpace(ctx.PathParam("distribution"))
|
||||
component := strings.TrimSpace(ctx.PathParam("component"))
|
||||
if distribution == "" || component == "" {
|
||||
apiError(ctx, http.StatusBadRequest, "invalid distribution or component")
|
||||
return
|
||||
}
|
||||
|
||||
upload, needToClose, err := ctx.UploadStream()
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if needToClose {
|
||||
defer upload.Close()
|
||||
}
|
||||
|
||||
buf, err := packages_module.CreateHashedBufferFromReader(upload)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer buf.Close()
|
||||
|
||||
pck, err := debian_module.ParsePackage(buf)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := buf.Seek(0, io.SeekStart); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
_, _, err = packages_service.CreatePackageOrAddFileToExisting(
|
||||
ctx,
|
||||
&packages_service.PackageCreationInfo{
|
||||
PackageInfo: packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeDebian,
|
||||
Name: pck.Name,
|
||||
Version: pck.Version,
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Metadata: pck.Metadata,
|
||||
},
|
||||
&packages_service.PackageFileCreationInfo{
|
||||
PackageFileInfo: packages_service.PackageFileInfo{
|
||||
Filename: fmt.Sprintf("%s_%s_%s.deb", pck.Name, pck.Version, pck.Architecture),
|
||||
CompositeKey: fmt.Sprintf("%s|%s", distribution, component),
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Data: buf,
|
||||
IsLead: true,
|
||||
Properties: map[string]string{
|
||||
debian_module.PropertyDistribution: distribution,
|
||||
debian_module.PropertyComponent: component,
|
||||
debian_module.PropertyArchitecture: pck.Architecture,
|
||||
debian_module.PropertyControl: pck.Control,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case packages_model.ErrDuplicatePackageVersion, packages_model.ErrDuplicatePackageFile:
|
||||
apiError(ctx, http.StatusConflict, err)
|
||||
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
default:
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := debian_service.BuildSpecificRepositoryFiles(ctx, ctx.Package.Owner.ID, distribution, component, pck.Architecture); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusCreated)
|
||||
}
|
||||
|
||||
func DownloadPackageFile(ctx *context.Context) {
|
||||
name := ctx.PathParam("name")
|
||||
version := ctx.PathParam("version")
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion(
|
||||
ctx,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeDebian,
|
||||
Name: name,
|
||||
Version: version,
|
||||
},
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: fmt.Sprintf("%s_%s_%s.deb", name, version, ctx.PathParam("architecture")),
|
||||
CompositeKey: fmt.Sprintf("%s|%s", ctx.PathParam("distribution"), ctx.PathParam("component")),
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf, &context.ServeHeaderOptions{
|
||||
ContentType: "application/vnd.debian.binary-package",
|
||||
Filename: pf.Name,
|
||||
LastModified: pf.CreatedUnix.AsLocalTime(),
|
||||
})
|
||||
}
|
||||
|
||||
func DeletePackageFile(ctx *context.Context) {
|
||||
distribution := ctx.PathParam("distribution")
|
||||
component := ctx.PathParam("component")
|
||||
name := ctx.PathParam("name")
|
||||
version := ctx.PathParam("version")
|
||||
architecture := ctx.PathParam("architecture")
|
||||
|
||||
owner := ctx.Package.Owner
|
||||
|
||||
var pd *packages_model.PackageDescriptor
|
||||
|
||||
err := db.WithTx(ctx, func(ctx stdctx.Context) error {
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, owner.ID, packages_model.TypeDebian, name, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pf, err := packages_model.GetFileForVersionByName(
|
||||
ctx,
|
||||
pv.ID,
|
||||
fmt.Sprintf("%s_%s_%s.deb", name, version, architecture),
|
||||
fmt.Sprintf("%s|%s", distribution, component),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := packages_service.DeletePackageFile(ctx, pf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
has, err := packages_model.HasVersionFileReferences(ctx, pv.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !has {
|
||||
pd, err = packages_model.GetPackageDescriptor(ctx, pv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := packages_service.DeletePackageVersionAndReferences(ctx, pv); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if pd != nil {
|
||||
notify_service.PackageDelete(ctx, ctx.Doer, pd)
|
||||
}
|
||||
|
||||
if err := debian_service.BuildSpecificRepositoryFiles(ctx, ctx.Package.Owner.ID, distribution, component, architecture); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
210
routers/api/packages/generic/generic.go
Normal file
210
routers/api/packages/generic/generic.go
Normal file
@@ -0,0 +1,210 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package generic
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
packages_module "code.gitea.io/gitea/modules/packages"
|
||||
"code.gitea.io/gitea/routers/api/packages/helper"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
)
|
||||
|
||||
var (
|
||||
packageNameRegex = regexp.MustCompile(`\A[-_+.\w]+\z`)
|
||||
filenameRegex = regexp.MustCompile(`\A[-_+=:;.()\[\]{}~!@#$%^& \w]+\z`)
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
ctx.PlainText(status, message)
|
||||
}
|
||||
|
||||
// DownloadPackageFile serves the specific generic package.
|
||||
func DownloadPackageFile(ctx *context.Context) {
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion(
|
||||
ctx,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeGeneric,
|
||||
Name: ctx.PathParam("packagename"),
|
||||
Version: ctx.PathParam("packageversion"),
|
||||
},
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: ctx.PathParam("filename"),
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(err, packages_model.ErrPackageFileNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
|
||||
func isValidPackageName(packageName string) bool {
|
||||
if len(packageName) == 1 && !unicode.IsLetter(rune(packageName[0])) && !unicode.IsNumber(rune(packageName[0])) {
|
||||
return false
|
||||
}
|
||||
return packageNameRegex.MatchString(packageName) && packageName != ".."
|
||||
}
|
||||
|
||||
func isValidFileName(filename string) bool {
|
||||
return filenameRegex.MatchString(filename) &&
|
||||
strings.TrimSpace(filename) == filename &&
|
||||
filename != "." && filename != ".."
|
||||
}
|
||||
|
||||
// UploadPackage uploads the specific generic package.
|
||||
// Duplicated packages get rejected.
|
||||
func UploadPackage(ctx *context.Context) {
|
||||
packageName := ctx.PathParam("packagename")
|
||||
filename := ctx.PathParam("filename")
|
||||
|
||||
if !isValidPackageName(packageName) {
|
||||
apiError(ctx, http.StatusBadRequest, errors.New("invalid package name"))
|
||||
return
|
||||
}
|
||||
|
||||
if !isValidFileName(filename) {
|
||||
apiError(ctx, http.StatusBadRequest, errors.New("invalid filename"))
|
||||
return
|
||||
}
|
||||
|
||||
packageVersion := ctx.PathParam("packageversion")
|
||||
if packageVersion != strings.TrimSpace(packageVersion) {
|
||||
apiError(ctx, http.StatusBadRequest, errors.New("invalid package version"))
|
||||
return
|
||||
}
|
||||
|
||||
upload, needToClose, err := ctx.UploadStream()
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if needToClose {
|
||||
defer upload.Close()
|
||||
}
|
||||
|
||||
buf, err := packages_module.CreateHashedBufferFromReader(upload)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer buf.Close()
|
||||
|
||||
_, _, err = packages_service.CreatePackageOrAddFileToExisting(
|
||||
ctx,
|
||||
&packages_service.PackageCreationInfo{
|
||||
PackageInfo: packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeGeneric,
|
||||
Name: packageName,
|
||||
Version: packageVersion,
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
},
|
||||
&packages_service.PackageFileCreationInfo{
|
||||
PackageFileInfo: packages_service.PackageFileInfo{
|
||||
Filename: filename,
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Data: buf,
|
||||
IsLead: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case packages_model.ErrDuplicatePackageFile:
|
||||
apiError(ctx, http.StatusConflict, err)
|
||||
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
default:
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusCreated)
|
||||
}
|
||||
|
||||
// DeletePackage deletes the specific generic package.
|
||||
func DeletePackage(ctx *context.Context) {
|
||||
err := packages_service.RemovePackageVersionByNameAndVersion(
|
||||
ctx,
|
||||
ctx.Doer,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeGeneric,
|
||||
Name: ctx.PathParam("packagename"),
|
||||
Version: ctx.PathParam("packageversion"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// DeletePackageFile deletes the specific file of a generic package.
|
||||
func DeletePackageFile(ctx *context.Context) {
|
||||
pv, pf, err := func() (*packages_model.PackageVersion, *packages_model.PackageFile, error) {
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeGeneric, ctx.PathParam("packagename"), ctx.PathParam("packageversion"))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
pf, err := packages_model.GetFileForVersionByName(ctx, pv.ID, ctx.PathParam("filename"), packages_model.EmptyFileKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return pv, pf, nil
|
||||
}()
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(err, packages_model.ErrPackageFileNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
pfs, err := packages_model.GetFilesByVersionID(ctx, pv.ID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(pfs) == 1 {
|
||||
if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := packages_service.DeletePackageFile(ctx, pf); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
65
routers/api/packages/generic/generic_test.go
Normal file
65
routers/api/packages/generic/generic_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package generic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestValidatePackageName(t *testing.T) {
|
||||
bad := []string{
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
"-",
|
||||
"a?b",
|
||||
"a b",
|
||||
"a/b",
|
||||
}
|
||||
for _, name := range bad {
|
||||
assert.False(t, isValidPackageName(name), "bad=%q", name)
|
||||
}
|
||||
|
||||
good := []string{
|
||||
"a",
|
||||
"1",
|
||||
"a-",
|
||||
"a_b",
|
||||
"c.d+",
|
||||
}
|
||||
for _, name := range good {
|
||||
assert.True(t, isValidPackageName(name), "good=%q", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFileName(t *testing.T) {
|
||||
bad := []string{
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
"a?b",
|
||||
"a/b",
|
||||
" a",
|
||||
"a ",
|
||||
}
|
||||
for _, name := range bad {
|
||||
assert.False(t, isValidFileName(name), "bad=%q", name)
|
||||
}
|
||||
|
||||
good := []string{
|
||||
"-",
|
||||
"a",
|
||||
"1",
|
||||
"a-",
|
||||
"a_b",
|
||||
"a b",
|
||||
"c.d+",
|
||||
`-_+=:;.()[]{}~!@#$%^& aA1`,
|
||||
}
|
||||
for _, name := range good {
|
||||
assert.True(t, isValidFileName(name), "good=%q", name)
|
||||
}
|
||||
}
|
||||
223
routers/api/packages/goproxy/goproxy.go
Normal file
223
routers/api/packages/goproxy/goproxy.go
Normal file
@@ -0,0 +1,223 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package goproxy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
packages_module "code.gitea.io/gitea/modules/packages"
|
||||
goproxy_module "code.gitea.io/gitea/modules/packages/goproxy"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/routers/api/packages/helper"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
ctx.PlainText(status, message)
|
||||
}
|
||||
|
||||
func EnumeratePackageVersions(ctx *context.Context) {
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeGo, ctx.PathParam("name"))
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pvs) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
|
||||
sort.Slice(pvs, func(i, j int) bool {
|
||||
return pvs[i].CreatedUnix < pvs[j].CreatedUnix
|
||||
})
|
||||
|
||||
ctx.Resp.Header().Set("Content-Type", "text/plain;charset=utf-8")
|
||||
|
||||
for _, pv := range pvs {
|
||||
fmt.Fprintln(ctx.Resp, pv.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func PackageVersionMetadata(ctx *context.Context) {
|
||||
pv, err := resolvePackage(ctx, ctx.Package.Owner.ID, ctx.PathParam("name"), ctx.PathParam("version"))
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, struct {
|
||||
Version string `json:"Version"`
|
||||
Time time.Time `json:"Time"`
|
||||
}{
|
||||
Version: pv.Version,
|
||||
Time: pv.CreatedUnix.AsLocalTime(),
|
||||
})
|
||||
}
|
||||
|
||||
func PackageVersionGoModContent(ctx *context.Context) {
|
||||
pv, err := resolvePackage(ctx, ctx.Package.Owner.ID, ctx.PathParam("name"), ctx.PathParam("version"))
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
pps, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypeVersion, pv.ID, goproxy_module.PropertyGoMod)
|
||||
if err != nil || len(pps) != 1 {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.PlainText(http.StatusOK, pps[0].Value)
|
||||
}
|
||||
|
||||
func DownloadPackageFile(ctx *context.Context) {
|
||||
pv, err := resolvePackage(ctx, ctx.Package.Owner.ID, ctx.PathParam("name"), ctx.PathParam("version"))
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
pfs, err := packages_model.GetFilesByVersionID(ctx, pv.ID)
|
||||
if err != nil || len(pfs) != 1 {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
s, u, _, err := packages_service.OpenFileForDownload(ctx, pfs[0], ctx.Req.Method)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pfs[0])
|
||||
}
|
||||
|
||||
func resolvePackage(ctx *context.Context, ownerID int64, name, version string) (*packages_model.PackageVersion, error) {
|
||||
var pv *packages_model.PackageVersion
|
||||
|
||||
if version == "latest" {
|
||||
pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
|
||||
OwnerID: ownerID,
|
||||
Type: packages_model.TypeGo,
|
||||
Name: packages_model.SearchValue{
|
||||
Value: name,
|
||||
ExactMatch: true,
|
||||
},
|
||||
IsInternal: optional.Some(false),
|
||||
Sort: packages_model.SortCreatedDesc,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(pvs) != 1 {
|
||||
return nil, packages_model.ErrPackageNotExist
|
||||
}
|
||||
|
||||
pv = pvs[0]
|
||||
} else {
|
||||
var err error
|
||||
pv, err = packages_model.GetVersionByNameAndVersion(ctx, ownerID, packages_model.TypeGo, name, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return pv, nil
|
||||
}
|
||||
|
||||
func UploadPackage(ctx *context.Context) {
|
||||
upload, needToClose, err := ctx.UploadStream()
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if needToClose {
|
||||
defer upload.Close()
|
||||
}
|
||||
|
||||
buf, err := packages_module.CreateHashedBufferFromReader(upload)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer buf.Close()
|
||||
|
||||
pck, err := goproxy_module.ParsePackage(buf, buf.Size())
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := buf.Seek(0, io.SeekStart); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
_, _, err = packages_service.CreatePackageAndAddFile(
|
||||
ctx,
|
||||
&packages_service.PackageCreationInfo{
|
||||
PackageInfo: packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeGo,
|
||||
Name: pck.Name,
|
||||
Version: pck.Version,
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
VersionProperties: map[string]string{
|
||||
goproxy_module.PropertyGoMod: pck.GoMod,
|
||||
},
|
||||
},
|
||||
&packages_service.PackageFileCreationInfo{
|
||||
PackageFileInfo: packages_service.PackageFileInfo{
|
||||
Filename: fmt.Sprintf("%v.zip", pck.Version),
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Data: buf,
|
||||
IsLead: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case packages_model.ErrDuplicatePackageVersion:
|
||||
apiError(ctx, http.StatusConflict, err)
|
||||
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
default:
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusCreated)
|
||||
}
|
||||
214
routers/api/packages/helm/helm.go
Normal file
214
routers/api/packages/helm/helm.go
Normal file
@@ -0,0 +1,214 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package helm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
packages_module "code.gitea.io/gitea/modules/packages"
|
||||
helm_module "code.gitea.io/gitea/modules/packages/helm"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/routers/api/packages/helper"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
type Error struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
ctx.JSON(status, Error{
|
||||
Error: message,
|
||||
})
|
||||
}
|
||||
|
||||
// Index generates the Helm charts index
|
||||
func Index(ctx *context.Context) {
|
||||
pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Type: packages_model.TypeHelm,
|
||||
IsInternal: optional.Some(false),
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := setting.AppURL + "api/packages/" + url.PathEscape(ctx.Package.Owner.Name) + "/helm"
|
||||
|
||||
type ChartVersion struct {
|
||||
helm_module.Metadata `yaml:",inline"`
|
||||
URLs []string `yaml:"urls"`
|
||||
Created time.Time `yaml:"created,omitempty"`
|
||||
Removed bool `yaml:"removed,omitempty"`
|
||||
Digest string `yaml:"digest,omitempty"`
|
||||
}
|
||||
|
||||
type ServerInfo struct {
|
||||
ContextPath string `yaml:"contextPath,omitempty"`
|
||||
}
|
||||
|
||||
type Index struct {
|
||||
APIVersion string `yaml:"apiVersion"`
|
||||
Entries map[string][]*ChartVersion `yaml:"entries"`
|
||||
Generated time.Time `yaml:"generated,omitempty"`
|
||||
ServerInfo *ServerInfo `yaml:"serverInfo,omitempty"`
|
||||
}
|
||||
|
||||
entries := make(map[string][]*ChartVersion)
|
||||
for _, pv := range pvs {
|
||||
metadata := &helm_module.Metadata{}
|
||||
if err := json.Unmarshal([]byte(pv.MetadataJSON), &metadata); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
entries[metadata.Name] = append(entries[metadata.Name], &ChartVersion{
|
||||
Metadata: *metadata,
|
||||
Created: pv.CreatedUnix.AsTime(),
|
||||
URLs: []string{fmt.Sprintf("%s/%s", baseURL, url.PathEscape(createFilename(metadata)))},
|
||||
})
|
||||
}
|
||||
|
||||
ctx.Resp.WriteHeader(http.StatusOK)
|
||||
_ = yaml.NewEncoder(ctx.Resp).Encode(&Index{
|
||||
APIVersion: "v1",
|
||||
Entries: entries,
|
||||
Generated: time.Now(),
|
||||
ServerInfo: &ServerInfo{
|
||||
ContextPath: setting.AppSubURL + "/api/packages/" + url.PathEscape(ctx.Package.Owner.Name) + "/helm",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// DownloadPackageFile serves the content of a package
|
||||
func DownloadPackageFile(ctx *context.Context) {
|
||||
filename := ctx.PathParam("filename")
|
||||
|
||||
pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Type: packages_model.TypeHelm,
|
||||
Name: packages_model.SearchValue{
|
||||
ExactMatch: true,
|
||||
Value: ctx.PathParam("package"),
|
||||
},
|
||||
HasFileWithName: filename,
|
||||
IsInternal: optional.Some(false),
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pvs) != 1 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion(
|
||||
ctx,
|
||||
pvs[0],
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: filename,
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageFileNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
|
||||
// UploadPackage creates a new package
|
||||
func UploadPackage(ctx *context.Context) {
|
||||
upload, needToClose, err := ctx.UploadStream()
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if needToClose {
|
||||
defer upload.Close()
|
||||
}
|
||||
|
||||
buf, err := packages_module.CreateHashedBufferFromReader(upload)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer buf.Close()
|
||||
|
||||
metadata, err := helm_module.ParseChartArchive(buf)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := buf.Seek(0, io.SeekStart); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
_, _, err = packages_service.CreatePackageOrAddFileToExisting(
|
||||
ctx,
|
||||
&packages_service.PackageCreationInfo{
|
||||
PackageInfo: packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeHelm,
|
||||
Name: metadata.Name,
|
||||
Version: metadata.Version,
|
||||
},
|
||||
SemverCompatible: true,
|
||||
Creator: ctx.Doer,
|
||||
Metadata: metadata,
|
||||
},
|
||||
&packages_service.PackageFileCreationInfo{
|
||||
PackageFileInfo: packages_service.PackageFileInfo{
|
||||
Filename: createFilename(metadata),
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Data: buf,
|
||||
IsLead: true,
|
||||
OverwriteExisting: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case packages_model.ErrDuplicatePackageVersion:
|
||||
apiError(ctx, http.StatusConflict, err)
|
||||
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
default:
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusCreated)
|
||||
}
|
||||
|
||||
func createFilename(metadata *helm_module.Metadata) string {
|
||||
return strings.ToLower(fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version))
|
||||
}
|
||||
61
routers/api/packages/helper/helper.go
Normal file
61
routers/api/packages/helper/helper.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package helper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
// ProcessErrorForUser logs the error and returns a user-error message for the end user.
|
||||
// If the status is http.StatusInternalServerError, the message is stripped for non-admin users in production.
|
||||
func ProcessErrorForUser(ctx *context.Context, status int, errObj any) string {
|
||||
var message string
|
||||
if err, ok := errObj.(error); ok {
|
||||
message = err.Error()
|
||||
} else if errObj != nil {
|
||||
message = fmt.Sprint(errObj)
|
||||
}
|
||||
|
||||
if status == http.StatusInternalServerError {
|
||||
log.Log(2, log.ERROR, "Package registry API internal error: %d %s", status, message)
|
||||
if setting.IsProd && (ctx.Doer == nil || !ctx.Doer.IsAdmin) {
|
||||
message = "internal server error"
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
log.Log(2, log.DEBUG, "Package registry API user error: %d %s", status, message)
|
||||
return message
|
||||
}
|
||||
|
||||
// ServePackageFile the content of the package file
|
||||
// If the url is set it will redirect the request, otherwise the content is copied to the response.
|
||||
func ServePackageFile(ctx *context.Context, s io.ReadSeekCloser, u *url.URL, pf *packages_model.PackageFile, forceOpts ...*context.ServeHeaderOptions) {
|
||||
if u != nil {
|
||||
ctx.Redirect(u.String())
|
||||
return
|
||||
}
|
||||
|
||||
defer s.Close()
|
||||
|
||||
var opts *context.ServeHeaderOptions
|
||||
if len(forceOpts) > 0 {
|
||||
opts = forceOpts[0]
|
||||
} else {
|
||||
opts = &context.ServeHeaderOptions{
|
||||
Filename: pf.Name,
|
||||
LastModified: pf.CreatedUnix.AsLocalTime(),
|
||||
}
|
||||
}
|
||||
|
||||
ctx.ServeContent(s, opts)
|
||||
}
|
||||
47
routers/api/packages/maven/api.go
Normal file
47
routers/api/packages/maven/api.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package maven
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
)
|
||||
|
||||
// MetadataResponse https://maven.apache.org/ref/3.2.5/maven-repository-metadata/repository-metadata.html
|
||||
type MetadataResponse struct {
|
||||
XMLName xml.Name `xml:"metadata"`
|
||||
GroupID string `xml:"groupId"`
|
||||
ArtifactID string `xml:"artifactId"`
|
||||
Release string `xml:"versioning>release,omitempty"`
|
||||
Latest string `xml:"versioning>latest"`
|
||||
Version []string `xml:"versioning>versions>version"`
|
||||
}
|
||||
|
||||
// pds is expected to be sorted ascending by CreatedUnix
|
||||
func createMetadataResponse(pds []*packages_model.PackageDescriptor, groupID, artifactID string) *MetadataResponse {
|
||||
var release *packages_model.PackageDescriptor
|
||||
|
||||
versions := make([]string, 0, len(pds))
|
||||
for _, pd := range pds {
|
||||
if !strings.HasSuffix(pd.Version.Version, "-SNAPSHOT") {
|
||||
release = pd
|
||||
}
|
||||
versions = append(versions, pd.Version.Version)
|
||||
}
|
||||
|
||||
latest := pds[len(pds)-1]
|
||||
|
||||
resp := &MetadataResponse{
|
||||
GroupID: groupID,
|
||||
ArtifactID: artifactID,
|
||||
Latest: latest.Version.Version,
|
||||
Version: versions,
|
||||
}
|
||||
if release != nil {
|
||||
resp.Release = release.Version.Version
|
||||
}
|
||||
return resp
|
||||
}
|
||||
477
routers/api/packages/maven/maven.go
Normal file
477
routers/api/packages/maven/maven.go
Normal file
@@ -0,0 +1,477 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package maven
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
"code.gitea.io/gitea/modules/globallock"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
packages_module "code.gitea.io/gitea/modules/packages"
|
||||
maven_module "code.gitea.io/gitea/modules/packages/maven"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/routers/api/packages/helper"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
)
|
||||
|
||||
const (
|
||||
mavenMetadataFile = "maven-metadata.xml"
|
||||
extensionMD5 = ".md5"
|
||||
extensionSHA1 = ".sha1"
|
||||
extensionSHA256 = ".sha256"
|
||||
extensionSHA512 = ".sha512"
|
||||
extensionPom = ".pom"
|
||||
extensionJar = ".jar"
|
||||
contentTypeJar = "application/java-archive"
|
||||
contentTypeXML = "text/xml"
|
||||
)
|
||||
|
||||
var (
|
||||
errInvalidParameters = errors.New("request parameters are invalid")
|
||||
illegalCharacters = regexp.MustCompile(`[\\/:"<>|?*]`)
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
// Maven client doesn't present the error message to end users; site admin can check the server logs that outputted by ProcessErrorForUser
|
||||
ctx.PlainText(status, message)
|
||||
}
|
||||
|
||||
// DownloadPackageFile serves the content of a package
|
||||
func DownloadPackageFile(ctx *context.Context) {
|
||||
handlePackageFile(ctx, true)
|
||||
}
|
||||
|
||||
// ProvidePackageFileHeader provides only the headers describing a package
|
||||
func ProvidePackageFileHeader(ctx *context.Context) {
|
||||
handlePackageFile(ctx, false)
|
||||
}
|
||||
|
||||
func handlePackageFile(ctx *context.Context, serveContent bool) {
|
||||
params, err := extractPathParameters(ctx)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
if params.IsMeta && params.Version == "" {
|
||||
serveMavenMetadata(ctx, params)
|
||||
} else {
|
||||
servePackageFile(ctx, params, serveContent)
|
||||
}
|
||||
}
|
||||
|
||||
func serveMavenMetadata(ctx *context.Context, params parameters) {
|
||||
// path pattern: /com/foo/project/maven-metadata.xml[.md5/.sha1/.sha256/.sha512]
|
||||
// in case there are legacy package names ("GroupID-ArtifactID") we need to check both, new packages always use ":" as separator("GroupID:ArtifactID")
|
||||
pvsLegacy, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeMaven, params.toInternalPackageNameLegacy())
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeMaven, params.toInternalPackageName())
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
pvs = append(pvsLegacy, pvs...)
|
||||
|
||||
if len(pvs) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, packages_model.ErrPackageNotExist)
|
||||
return
|
||||
}
|
||||
|
||||
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
sort.Slice(pds, func(i, j int) bool {
|
||||
// Maven and Gradle order packages by their creation timestamp and not by their version string
|
||||
return pds[i].Version.CreatedUnix < pds[j].Version.CreatedUnix
|
||||
})
|
||||
|
||||
xmlMetadata, err := xml.Marshal(createMetadataResponse(pds, params.GroupID, params.ArtifactID))
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
xmlMetadataWithHeader := append([]byte(xml.Header), xmlMetadata...)
|
||||
|
||||
latest := pds[len(pds)-1]
|
||||
// http.TimeFormat required a UTC time, refer to https://pkg.go.dev/net/http#TimeFormat
|
||||
lastModified := latest.Version.CreatedUnix.AsTime().UTC().Format(http.TimeFormat)
|
||||
ctx.Resp.Header().Set("Last-Modified", lastModified)
|
||||
|
||||
ext := strings.ToLower(path.Ext(params.Filename))
|
||||
if isChecksumExtension(ext) {
|
||||
var hash []byte
|
||||
switch ext {
|
||||
case extensionMD5:
|
||||
tmp := md5.Sum(xmlMetadataWithHeader)
|
||||
hash = tmp[:]
|
||||
case extensionSHA1:
|
||||
tmp := sha1.Sum(xmlMetadataWithHeader)
|
||||
hash = tmp[:]
|
||||
case extensionSHA256:
|
||||
tmp := sha256.Sum256(xmlMetadataWithHeader)
|
||||
hash = tmp[:]
|
||||
case extensionSHA512:
|
||||
tmp := sha512.Sum512(xmlMetadataWithHeader)
|
||||
hash = tmp[:]
|
||||
}
|
||||
ctx.PlainText(http.StatusOK, hex.EncodeToString(hash))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Resp.Header().Set("Content-Length", strconv.Itoa(len(xmlMetadataWithHeader)))
|
||||
ctx.Resp.Header().Set("Content-Type", contentTypeXML)
|
||||
|
||||
_, _ = ctx.Resp.Write(xmlMetadataWithHeader)
|
||||
}
|
||||
|
||||
func servePackageFile(ctx *context.Context, params parameters, serveContent bool) {
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeMaven, params.toInternalPackageName(), params.Version)
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
pv, err = packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeMaven, params.toInternalPackageNameLegacy(), params.Version)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
filename := params.Filename
|
||||
|
||||
ext := strings.ToLower(path.Ext(filename))
|
||||
if isChecksumExtension(ext) {
|
||||
filename = filename[:len(filename)-len(ext)]
|
||||
}
|
||||
|
||||
pf, err := packages_model.GetFileForVersionByName(ctx, pv.ID, filename, packages_model.EmptyFileKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageFileNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
pb, err := packages_model.GetBlobByID(ctx, pf.BlobID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if isChecksumExtension(ext) {
|
||||
var hash string
|
||||
switch ext {
|
||||
case extensionMD5:
|
||||
hash = pb.HashMD5
|
||||
case extensionSHA1:
|
||||
hash = pb.HashSHA1
|
||||
case extensionSHA256:
|
||||
hash = pb.HashSHA256
|
||||
case extensionSHA512:
|
||||
hash = pb.HashSHA512
|
||||
}
|
||||
ctx.PlainText(http.StatusOK, hash)
|
||||
return
|
||||
}
|
||||
|
||||
opts := &context.ServeHeaderOptions{
|
||||
ContentLength: &pb.Size,
|
||||
LastModified: pf.CreatedUnix.AsLocalTime(),
|
||||
}
|
||||
switch ext {
|
||||
case extensionJar:
|
||||
opts.ContentType = contentTypeJar
|
||||
case extensionPom:
|
||||
opts.ContentType = contentTypeXML
|
||||
}
|
||||
|
||||
if !serveContent {
|
||||
ctx.SetServeHeaders(opts)
|
||||
ctx.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
s, u, _, err := packages_service.OpenBlobForDownload(ctx, pf, pb, ctx.Req.Method, nil)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
opts.Filename = pf.Name
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf, opts)
|
||||
}
|
||||
|
||||
func mavenPkgNameKey(packageName string) string {
|
||||
return "pkg_maven_" + packageName
|
||||
}
|
||||
|
||||
// UploadPackageFile adds a file to the package. If the package does not exist, it gets created.
|
||||
func UploadPackageFile(ctx *context.Context) {
|
||||
params, err := extractPathParameters(ctx)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Ignore the package index /<name>/maven-metadata.xml
|
||||
if params.IsMeta && params.Version == "" {
|
||||
ctx.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
packageName := params.toInternalPackageName()
|
||||
if ctx.FormBool("use_legacy_package_name") {
|
||||
// for testing purpose only
|
||||
packageName = params.toInternalPackageNameLegacy()
|
||||
}
|
||||
|
||||
// for the same package, only one upload at a time
|
||||
releaser, err := globallock.Lock(ctx, mavenPkgNameKey(packageName))
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer releaser()
|
||||
|
||||
buf, err := packages_module.CreateHashedBufferFromReader(ctx.Req.Body)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer buf.Close()
|
||||
|
||||
pvci := &packages_service.PackageCreationInfo{
|
||||
PackageInfo: packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeMaven,
|
||||
Name: packageName,
|
||||
Version: params.Version,
|
||||
},
|
||||
SemverCompatible: false,
|
||||
Creator: ctx.Doer,
|
||||
}
|
||||
|
||||
// old maven package uses "groupId-artifactId" as package name, so we need to update to the new format "groupId:artifactId"
|
||||
legacyPackage, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeMaven, params.toInternalPackageNameLegacy())
|
||||
if err != nil && !errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
} else if legacyPackage != nil {
|
||||
err = packages_model.UpdatePackageNameByID(ctx, ctx.Package.Owner.ID, packages_model.TypeMaven, legacyPackage.ID, packageName)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ext := path.Ext(params.Filename)
|
||||
|
||||
// Do not upload checksum files but compare the hashes.
|
||||
if isChecksumExtension(ext) {
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, pvci.Owner.ID, pvci.PackageType, pvci.Name, pvci.Version)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
pf, err := packages_model.GetFileForVersionByName(ctx, pv.ID, params.Filename[:len(params.Filename)-len(ext)], packages_model.EmptyFileKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageFileNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
pb, err := packages_model.GetBlobByID(ctx, pf.BlobID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := io.ReadAll(buf)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if (ext == extensionMD5 && pb.HashMD5 != string(hash)) ||
|
||||
(ext == extensionSHA1 && pb.HashSHA1 != string(hash)) ||
|
||||
(ext == extensionSHA256 && pb.HashSHA256 != string(hash)) ||
|
||||
(ext == extensionSHA512 && pb.HashSHA512 != string(hash)) {
|
||||
apiError(ctx, http.StatusBadRequest, "hash mismatch")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
pfci := &packages_service.PackageFileCreationInfo{
|
||||
PackageFileInfo: packages_service.PackageFileInfo{
|
||||
Filename: params.Filename,
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Data: buf,
|
||||
IsLead: false,
|
||||
OverwriteExisting: params.IsMeta,
|
||||
}
|
||||
|
||||
// If it's the package pom file extract the metadata
|
||||
if ext == extensionPom {
|
||||
pfci.IsLead = true
|
||||
|
||||
var err error
|
||||
pvci.Metadata, err = maven_module.ParsePackageMetaData(buf)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
if pvci.Metadata != nil {
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, pvci.Owner.ID, pvci.PackageType, pvci.Name, pvci.Version)
|
||||
if err != nil && !errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if pv != nil {
|
||||
raw, err := json.Marshal(pvci.Metadata)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
pv.MetadataJSON = string(raw)
|
||||
if err := packages_model.UpdateVersion(ctx, pv); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := buf.Seek(0, io.SeekStart); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_, _, err = packages_service.CreatePackageOrAddFileToExisting(
|
||||
ctx,
|
||||
pvci,
|
||||
pfci,
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case packages_model.ErrDuplicatePackageFile:
|
||||
apiError(ctx, http.StatusConflict, err)
|
||||
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
default:
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusCreated)
|
||||
}
|
||||
|
||||
func isChecksumExtension(ext string) bool {
|
||||
return ext == extensionMD5 || ext == extensionSHA1 || ext == extensionSHA256 || ext == extensionSHA512
|
||||
}
|
||||
|
||||
type parameters struct {
|
||||
GroupID string
|
||||
ArtifactID string
|
||||
Version string
|
||||
Filename string
|
||||
IsMeta bool
|
||||
}
|
||||
|
||||
func (p *parameters) toInternalPackageName() string {
|
||||
// there cuold be 2 choices: "/" or ":"
|
||||
// Maven says: "groupId:artifactId:version" in their document: https://maven.apache.org/pom.html#Maven_Coordinates
|
||||
// but it would be slightly ugly in URL: "/-/packages/maven/group-id%3Aartifact-id"
|
||||
return p.GroupID + ":" + p.ArtifactID
|
||||
}
|
||||
|
||||
func (p *parameters) toInternalPackageNameLegacy() string {
|
||||
return p.GroupID + "-" + p.ArtifactID
|
||||
}
|
||||
|
||||
func extractPathParameters(ctx *context.Context) (parameters, error) {
|
||||
parts := strings.Split(ctx.PathParam("*"), "/")
|
||||
|
||||
// formats:
|
||||
// * /com/group/id/artifactId/maven-metadata.xml[.md5|.sha1|.sha256|.sha512]
|
||||
// * /com/group/id/artifactId/version-SNAPSHOT/maven-metadata.xml[.md5|.sha1|.sha256|.sha512]
|
||||
// * /com/group/id/artifactId/version/any-file
|
||||
// * /com/group/id/artifactId/version-SNAPSHOT/any-file
|
||||
|
||||
p := parameters{
|
||||
Filename: parts[len(parts)-1],
|
||||
}
|
||||
|
||||
p.IsMeta = p.Filename == mavenMetadataFile ||
|
||||
p.Filename == mavenMetadataFile+extensionMD5 ||
|
||||
p.Filename == mavenMetadataFile+extensionSHA1 ||
|
||||
p.Filename == mavenMetadataFile+extensionSHA256 ||
|
||||
p.Filename == mavenMetadataFile+extensionSHA512
|
||||
|
||||
parts = parts[:len(parts)-1]
|
||||
if len(parts) == 0 {
|
||||
return p, errInvalidParameters
|
||||
}
|
||||
|
||||
p.Version = parts[len(parts)-1]
|
||||
if p.IsMeta && !strings.HasSuffix(p.Version, "-SNAPSHOT") {
|
||||
p.Version = ""
|
||||
} else {
|
||||
parts = parts[:len(parts)-1]
|
||||
}
|
||||
|
||||
if illegalCharacters.MatchString(p.Version) {
|
||||
return p, errInvalidParameters
|
||||
}
|
||||
|
||||
if len(parts) < 2 {
|
||||
return p, errInvalidParameters
|
||||
}
|
||||
|
||||
p.ArtifactID = parts[len(parts)-1]
|
||||
p.GroupID = strings.Join(parts[:len(parts)-1], ".")
|
||||
|
||||
if illegalCharacters.MatchString(p.GroupID) || illegalCharacters.MatchString(p.ArtifactID) {
|
||||
return p, errInvalidParameters
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
115
routers/api/packages/npm/api.go
Normal file
115
routers/api/packages/npm/api.go
Normal file
@@ -0,0 +1,115 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package npm
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
npm_module "code.gitea.io/gitea/modules/packages/npm"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
)
|
||||
|
||||
func createPackageMetadataResponse(registryURL string, pds []*packages_model.PackageDescriptor) *npm_module.PackageMetadata {
|
||||
sort.Slice(pds, func(i, j int) bool {
|
||||
return pds[i].SemVer.LessThan(pds[j].SemVer)
|
||||
})
|
||||
|
||||
versions := make(map[string]*npm_module.PackageMetadataVersion)
|
||||
distTags := make(map[string]string)
|
||||
for _, pd := range pds {
|
||||
versions[pd.SemVer.String()] = createPackageMetadataVersion(registryURL, pd)
|
||||
|
||||
for _, pvp := range pd.VersionProperties {
|
||||
if pvp.Name == npm_module.TagProperty {
|
||||
distTags[pvp.Value] = pd.Version.Version
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
latest := pds[len(pds)-1]
|
||||
|
||||
metadata := latest.Metadata.(*npm_module.Metadata)
|
||||
|
||||
return &npm_module.PackageMetadata{
|
||||
ID: latest.Package.Name,
|
||||
Name: latest.Package.Name,
|
||||
DistTags: distTags,
|
||||
Description: metadata.Description,
|
||||
Readme: metadata.Readme,
|
||||
Homepage: metadata.ProjectURL,
|
||||
Author: npm_module.User{Name: metadata.Author},
|
||||
License: metadata.License,
|
||||
Versions: versions,
|
||||
Repository: metadata.Repository,
|
||||
}
|
||||
}
|
||||
|
||||
func createPackageMetadataVersion(registryURL string, pd *packages_model.PackageDescriptor) *npm_module.PackageMetadataVersion {
|
||||
hashBytes, _ := hex.DecodeString(pd.Files[0].Blob.HashSHA512)
|
||||
|
||||
metadata := pd.Metadata.(*npm_module.Metadata)
|
||||
|
||||
return &npm_module.PackageMetadataVersion{
|
||||
ID: fmt.Sprintf("%s@%s", pd.Package.Name, pd.Version.Version),
|
||||
Name: pd.Package.Name,
|
||||
Version: pd.Version.Version,
|
||||
Description: metadata.Description,
|
||||
Author: npm_module.User{Name: metadata.Author},
|
||||
Homepage: metadata.ProjectURL,
|
||||
License: metadata.License,
|
||||
Dependencies: metadata.Dependencies,
|
||||
BundleDependencies: metadata.BundleDependencies,
|
||||
DevDependencies: metadata.DevelopmentDependencies,
|
||||
PeerDependencies: metadata.PeerDependencies,
|
||||
PeerDependenciesMeta: metadata.PeerDependenciesMeta,
|
||||
OptionalDependencies: metadata.OptionalDependencies,
|
||||
Readme: metadata.Readme,
|
||||
Bin: metadata.Bin,
|
||||
Dist: npm_module.PackageDistribution{
|
||||
Shasum: pd.Files[0].Blob.HashSHA1,
|
||||
Integrity: "sha512-" + base64.StdEncoding.EncodeToString(hashBytes),
|
||||
Tarball: fmt.Sprintf("%s/%s/-/%s/%s", registryURL, url.QueryEscape(pd.Package.Name), url.PathEscape(pd.Version.Version), url.PathEscape(pd.Files[0].File.LowerName)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createPackageSearchResponse(pds []*packages_model.PackageDescriptor, total int64) *npm_module.PackageSearch {
|
||||
objects := make([]*npm_module.PackageSearchObject, 0, len(pds))
|
||||
for _, pd := range pds {
|
||||
metadata := pd.Metadata.(*npm_module.Metadata)
|
||||
|
||||
scope := metadata.Scope
|
||||
if scope == "" {
|
||||
scope = "unscoped"
|
||||
}
|
||||
|
||||
objects = append(objects, &npm_module.PackageSearchObject{
|
||||
Package: &npm_module.PackageSearchPackage{
|
||||
Scope: scope,
|
||||
Name: metadata.Name,
|
||||
Version: pd.Version.Version,
|
||||
Date: pd.Version.CreatedUnix.AsLocalTime(),
|
||||
Description: metadata.Description,
|
||||
Author: npm_module.User{Name: metadata.Author},
|
||||
Publisher: npm_module.User{Name: pd.Owner.Name},
|
||||
Maintainers: []npm_module.User{}, // npm cli needs this field
|
||||
Keywords: metadata.Keywords,
|
||||
Links: &npm_module.PackageSearchPackageLinks{
|
||||
Registry: setting.AppURL + "api/packages/" + pd.Owner.Name + "/npm",
|
||||
Homepage: metadata.ProjectURL,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return &npm_module.PackageSearch{
|
||||
Objects: objects,
|
||||
Total: total,
|
||||
}
|
||||
}
|
||||
463
routers/api/packages/npm/npm.go
Normal file
463
routers/api/packages/npm/npm.go
Normal file
@@ -0,0 +1,463 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package npm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
std_ctx "context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
access_model "code.gitea.io/gitea/models/perm/access"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unit"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
packages_module "code.gitea.io/gitea/modules/packages"
|
||||
npm_module "code.gitea.io/gitea/modules/packages/npm"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/routers/api/packages/helper"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
|
||||
"github.com/hashicorp/go-version"
|
||||
)
|
||||
|
||||
// errInvalidTagName indicates an invalid tag name
|
||||
var errInvalidTagName = errors.New("The tag name is invalid")
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
ctx.JSON(status, map[string]string{
|
||||
"error": message,
|
||||
})
|
||||
}
|
||||
|
||||
// packageNameFromParams gets the package name from the url parameters
|
||||
// Variations: /name/, /@scope/name/, /@scope%2Fname/
|
||||
func packageNameFromParams(ctx *context.Context) string {
|
||||
scope := ctx.PathParam("scope")
|
||||
id := ctx.PathParam("id")
|
||||
if scope != "" {
|
||||
return fmt.Sprintf("@%s/%s", scope, id)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// PackageMetadata returns the metadata for a single package
|
||||
func PackageMetadata(ctx *context.Context) {
|
||||
packageName := packageNameFromParams(ctx)
|
||||
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageName)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pvs) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
|
||||
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp := createPackageMetadataResponse(
|
||||
setting.AppURL+"api/packages/"+ctx.Package.Owner.Name+"/npm",
|
||||
pds,
|
||||
)
|
||||
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// DownloadPackageFile serves the content of a package
|
||||
func DownloadPackageFile(ctx *context.Context) {
|
||||
packageName := packageNameFromParams(ctx)
|
||||
packageVersion := ctx.PathParam("version")
|
||||
filename := ctx.PathParam("filename")
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion(
|
||||
ctx,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeNpm,
|
||||
Name: packageName,
|
||||
Version: packageVersion,
|
||||
},
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: filename,
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(err, packages_model.ErrPackageFileNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
|
||||
// DownloadPackageFileByName finds the version and serves the contents of a package
|
||||
func DownloadPackageFileByName(ctx *context.Context) {
|
||||
filename := ctx.PathParam("filename")
|
||||
|
||||
pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Type: packages_model.TypeNpm,
|
||||
Name: packages_model.SearchValue{
|
||||
ExactMatch: true,
|
||||
Value: packageNameFromParams(ctx),
|
||||
},
|
||||
HasFileWithName: filename,
|
||||
IsInternal: optional.Some(false),
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pvs) != 1 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion(
|
||||
ctx,
|
||||
pvs[0],
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: filename,
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageFileNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
|
||||
// UploadPackage creates a new package
|
||||
func UploadPackage(ctx *context.Context) {
|
||||
npmPackage, err := npm_module.ParsePackage(ctx.Req.Body)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
repo, err := repo_model.GetRepositoryByURLRelax(ctx, npmPackage.Metadata.Repository.URL)
|
||||
if err == nil {
|
||||
canWrite := repo.OwnerID == ctx.Doer.ID
|
||||
|
||||
if !canWrite {
|
||||
perms, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
canWrite = perms.CanWrite(unit.TypePackages)
|
||||
}
|
||||
|
||||
if !canWrite {
|
||||
apiError(ctx, http.StatusForbidden, "no permission to upload this package")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
buf, err := packages_module.CreateHashedBufferFromReader(bytes.NewReader(npmPackage.Data))
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer buf.Close()
|
||||
|
||||
pv, _, err := packages_service.CreatePackageAndAddFile(
|
||||
ctx,
|
||||
&packages_service.PackageCreationInfo{
|
||||
PackageInfo: packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeNpm,
|
||||
Name: npmPackage.Name,
|
||||
Version: npmPackage.Version,
|
||||
},
|
||||
SemverCompatible: true,
|
||||
Creator: ctx.Doer,
|
||||
Metadata: npmPackage.Metadata,
|
||||
},
|
||||
&packages_service.PackageFileCreationInfo{
|
||||
PackageFileInfo: packages_service.PackageFileInfo{
|
||||
Filename: npmPackage.Filename,
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Data: buf,
|
||||
IsLead: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case packages_model.ErrDuplicatePackageVersion:
|
||||
apiError(ctx, http.StatusConflict, err)
|
||||
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
default:
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for _, tag := range npmPackage.DistTags {
|
||||
if err := setPackageTag(ctx, tag, pv, false); err != nil {
|
||||
if err == errInvalidTagName {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if repo != nil {
|
||||
if err := packages_model.SetRepositoryLink(ctx, pv.PackageID, repo.ID); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusCreated)
|
||||
}
|
||||
|
||||
// DeletePreview does nothing
|
||||
// The client tells the server what package version it knows about after deleting a version.
|
||||
func DeletePreview(ctx *context.Context) {
|
||||
ctx.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// DeletePackageVersion deletes the package version
|
||||
func DeletePackageVersion(ctx *context.Context) {
|
||||
packageName := packageNameFromParams(ctx)
|
||||
packageVersion := ctx.PathParam("version")
|
||||
|
||||
err := packages_service.RemovePackageVersionByNameAndVersion(
|
||||
ctx,
|
||||
ctx.Doer,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeNpm,
|
||||
Name: packageName,
|
||||
Version: packageVersion,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// DeletePackage deletes the package and all versions
|
||||
func DeletePackage(ctx *context.Context) {
|
||||
packageName := packageNameFromParams(ctx)
|
||||
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageName)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(pvs) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, pv := range pvs {
|
||||
if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// ListPackageTags returns all tags for a package
|
||||
func ListPackageTags(ctx *context.Context) {
|
||||
packageName := packageNameFromParams(ctx)
|
||||
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageName)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
tags := make(map[string]string)
|
||||
for _, pv := range pvs {
|
||||
pvps, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypeVersion, pv.ID, npm_module.TagProperty)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
for _, pvp := range pvps {
|
||||
tags[pvp.Value] = pv.Version
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, tags)
|
||||
}
|
||||
|
||||
// AddPackageTag adds a tag to the package
|
||||
func AddPackageTag(ctx *context.Context) {
|
||||
packageName := packageNameFromParams(ctx)
|
||||
|
||||
body, err := io.ReadAll(ctx.Req.Body)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
version := strings.Trim(string(body), "\"") // is as "version" in the body
|
||||
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageName, version)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := setPackageTag(ctx, ctx.PathParam("tag"), pv, false); err != nil {
|
||||
if err == errInvalidTagName {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// DeletePackageTag deletes a package tag
|
||||
func DeletePackageTag(ctx *context.Context) {
|
||||
packageName := packageNameFromParams(ctx)
|
||||
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageName)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(pvs) != 0 {
|
||||
if err := setPackageTag(ctx, ctx.PathParam("tag"), pvs[0], true); err != nil {
|
||||
if err == errInvalidTagName {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setPackageTag(ctx std_ctx.Context, tag string, pv *packages_model.PackageVersion, deleteOnly bool) error {
|
||||
if tag == "" {
|
||||
return errInvalidTagName
|
||||
}
|
||||
_, err := version.NewVersion(tag)
|
||||
if err == nil {
|
||||
return errInvalidTagName
|
||||
}
|
||||
|
||||
return db.WithTx(ctx, func(ctx std_ctx.Context) error {
|
||||
pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
|
||||
PackageID: pv.PackageID,
|
||||
Properties: map[string]string{
|
||||
npm_module.TagProperty: tag,
|
||||
},
|
||||
IsInternal: optional.Some(false),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(pvs) == 1 {
|
||||
pvps, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypeVersion, pvs[0].ID, npm_module.TagProperty)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, pvp := range pvps {
|
||||
if pvp.Value == tag {
|
||||
if err := packages_model.DeletePropertyByID(ctx, pvp.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !deleteOnly {
|
||||
_, err = packages_model.InsertProperty(ctx, packages_model.PropertyTypeVersion, pv.ID, npm_module.TagProperty, tag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func PackageSearch(ctx *context.Context) {
|
||||
pvs, total, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Type: packages_model.TypeNpm,
|
||||
IsInternal: optional.Some(false),
|
||||
Name: packages_model.SearchValue{
|
||||
ExactMatch: false,
|
||||
Value: ctx.FormTrim("text"),
|
||||
},
|
||||
Paginator: db.NewAbsoluteListOptions(
|
||||
ctx.FormInt("from"),
|
||||
ctx.FormInt("size"),
|
||||
),
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp := createPackageSearchResponse(
|
||||
pds,
|
||||
total,
|
||||
)
|
||||
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
420
routers/api/packages/nuget/api_v2.go
Normal file
420
routers/api/packages/nuget/api_v2.go
Normal file
@@ -0,0 +1,420 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package nuget
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
nuget_module "code.gitea.io/gitea/modules/packages/nuget"
|
||||
)
|
||||
|
||||
type AtomTitle struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Text string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type ServiceCollection struct {
|
||||
Href string `xml:"href,attr"`
|
||||
Title AtomTitle `xml:"atom:title"`
|
||||
}
|
||||
|
||||
type ServiceWorkspace struct {
|
||||
Title AtomTitle `xml:"atom:title"`
|
||||
Collection ServiceCollection `xml:"collection"`
|
||||
}
|
||||
|
||||
type ServiceIndexResponseV2 struct {
|
||||
XMLName xml.Name `xml:"service"`
|
||||
Base string `xml:"base,attr"`
|
||||
Xmlns string `xml:"xmlns,attr"`
|
||||
XmlnsAtom string `xml:"xmlns:atom,attr"`
|
||||
Workspace ServiceWorkspace `xml:"workspace"`
|
||||
}
|
||||
|
||||
type EdmxPropertyRef struct {
|
||||
Name string `xml:"Name,attr"`
|
||||
}
|
||||
|
||||
type EdmxProperty struct {
|
||||
Name string `xml:"Name,attr"`
|
||||
Type string `xml:"Type,attr"`
|
||||
Nullable bool `xml:"Nullable,attr"`
|
||||
}
|
||||
|
||||
type EdmxEntityType struct {
|
||||
Name string `xml:"Name,attr"`
|
||||
HasStream bool `xml:"m:HasStream,attr"`
|
||||
Keys []EdmxPropertyRef `xml:"Key>PropertyRef"`
|
||||
Properties []EdmxProperty `xml:"Property"`
|
||||
}
|
||||
|
||||
type EdmxFunctionParameter struct {
|
||||
Name string `xml:"Name,attr"`
|
||||
Type string `xml:"Type,attr"`
|
||||
}
|
||||
|
||||
type EdmxFunctionImport struct {
|
||||
Name string `xml:"Name,attr"`
|
||||
ReturnType string `xml:"ReturnType,attr"`
|
||||
EntitySet string `xml:"EntitySet,attr"`
|
||||
Parameter []EdmxFunctionParameter `xml:"Parameter"`
|
||||
}
|
||||
|
||||
type EdmxEntitySet struct {
|
||||
Name string `xml:"Name,attr"`
|
||||
EntityType string `xml:"EntityType,attr"`
|
||||
}
|
||||
|
||||
type EdmxEntityContainer struct {
|
||||
Name string `xml:"Name,attr"`
|
||||
IsDefaultEntityContainer bool `xml:"m:IsDefaultEntityContainer,attr"`
|
||||
EntitySet EdmxEntitySet `xml:"EntitySet"`
|
||||
FunctionImports []EdmxFunctionImport `xml:"FunctionImport"`
|
||||
}
|
||||
|
||||
type EdmxSchema struct {
|
||||
Xmlns string `xml:"xmlns,attr"`
|
||||
Namespace string `xml:"Namespace,attr"`
|
||||
EntityType *EdmxEntityType `xml:"EntityType,omitempty"`
|
||||
EntityContainer *EdmxEntityContainer `xml:"EntityContainer,omitempty"`
|
||||
}
|
||||
|
||||
type EdmxDataServices struct {
|
||||
XmlnsM string `xml:"xmlns:m,attr"`
|
||||
DataServiceVersion string `xml:"m:DataServiceVersion,attr"`
|
||||
MaxDataServiceVersion string `xml:"m:MaxDataServiceVersion,attr"`
|
||||
Schema []EdmxSchema `xml:"Schema"`
|
||||
}
|
||||
|
||||
type EdmxMetadata struct {
|
||||
XMLName xml.Name `xml:"edmx:Edmx"`
|
||||
XmlnsEdmx string `xml:"xmlns:edmx,attr"`
|
||||
Version string `xml:"Version,attr"`
|
||||
DataServices EdmxDataServices `xml:"edmx:DataServices"`
|
||||
}
|
||||
|
||||
var Metadata = &EdmxMetadata{
|
||||
XmlnsEdmx: "http://schemas.microsoft.com/ado/2007/06/edmx",
|
||||
Version: "1.0",
|
||||
DataServices: EdmxDataServices{
|
||||
XmlnsM: "http://schemas.microsoft.com/ado/2007/08/dataservices/metadata",
|
||||
DataServiceVersion: "2.0",
|
||||
MaxDataServiceVersion: "2.0",
|
||||
Schema: []EdmxSchema{
|
||||
{
|
||||
Xmlns: "http://schemas.microsoft.com/ado/2006/04/edm",
|
||||
Namespace: "NuGetGallery.OData",
|
||||
EntityType: &EdmxEntityType{
|
||||
Name: "V2FeedPackage",
|
||||
HasStream: true,
|
||||
Keys: []EdmxPropertyRef{
|
||||
{Name: "Id"},
|
||||
{Name: "Version"},
|
||||
},
|
||||
Properties: []EdmxProperty{
|
||||
{
|
||||
Name: "Id",
|
||||
Type: "Edm.String",
|
||||
},
|
||||
{
|
||||
Name: "Version",
|
||||
Type: "Edm.String",
|
||||
},
|
||||
{
|
||||
Name: "NormalizedVersion",
|
||||
Type: "Edm.String",
|
||||
Nullable: true,
|
||||
},
|
||||
{
|
||||
Name: "Authors",
|
||||
Type: "Edm.String",
|
||||
Nullable: true,
|
||||
},
|
||||
{
|
||||
Name: "Created",
|
||||
Type: "Edm.DateTime",
|
||||
},
|
||||
{
|
||||
Name: "Dependencies",
|
||||
Type: "Edm.String",
|
||||
},
|
||||
{
|
||||
Name: "Description",
|
||||
Type: "Edm.String",
|
||||
},
|
||||
{
|
||||
Name: "DownloadCount",
|
||||
Type: "Edm.Int64",
|
||||
},
|
||||
{
|
||||
Name: "LastUpdated",
|
||||
Type: "Edm.DateTime",
|
||||
},
|
||||
{
|
||||
Name: "Published",
|
||||
Type: "Edm.DateTime",
|
||||
},
|
||||
{
|
||||
Name: "PackageSize",
|
||||
Type: "Edm.Int64",
|
||||
},
|
||||
{
|
||||
Name: "ProjectUrl",
|
||||
Type: "Edm.String",
|
||||
Nullable: true,
|
||||
},
|
||||
{
|
||||
Name: "ReleaseNotes",
|
||||
Type: "Edm.String",
|
||||
Nullable: true,
|
||||
},
|
||||
{
|
||||
Name: "RequireLicenseAcceptance",
|
||||
Type: "Edm.Boolean",
|
||||
Nullable: false,
|
||||
},
|
||||
{
|
||||
Name: "Title",
|
||||
Type: "Edm.String",
|
||||
Nullable: true,
|
||||
},
|
||||
{
|
||||
Name: "VersionDownloadCount",
|
||||
Type: "Edm.Int64",
|
||||
Nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Xmlns: "http://schemas.microsoft.com/ado/2006/04/edm",
|
||||
Namespace: "NuGetGallery",
|
||||
EntityContainer: &EdmxEntityContainer{
|
||||
Name: "V2FeedContext",
|
||||
IsDefaultEntityContainer: true,
|
||||
EntitySet: EdmxEntitySet{
|
||||
Name: "Packages",
|
||||
EntityType: "NuGetGallery.OData.V2FeedPackage",
|
||||
},
|
||||
FunctionImports: []EdmxFunctionImport{
|
||||
{
|
||||
Name: "Search",
|
||||
ReturnType: "Collection(NuGetGallery.OData.V2FeedPackage)",
|
||||
EntitySet: "Packages",
|
||||
Parameter: []EdmxFunctionParameter{
|
||||
{
|
||||
Name: "searchTerm",
|
||||
Type: "Edm.String",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "FindPackagesById",
|
||||
ReturnType: "Collection(NuGetGallery.OData.V2FeedPackage)",
|
||||
EntitySet: "Packages",
|
||||
Parameter: []EdmxFunctionParameter{
|
||||
{
|
||||
Name: "id",
|
||||
Type: "Edm.String",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
type FeedEntryCategory struct {
|
||||
Term string `xml:"term,attr"`
|
||||
Scheme string `xml:"scheme,attr"`
|
||||
}
|
||||
|
||||
type FeedEntryLink struct {
|
||||
Rel string `xml:"rel,attr"`
|
||||
Href string `xml:"href,attr"`
|
||||
}
|
||||
|
||||
type TypedValue[T any] struct {
|
||||
Type string `xml:"type,attr,omitempty"`
|
||||
Value T `xml:",chardata"`
|
||||
}
|
||||
|
||||
type FeedEntryProperties struct {
|
||||
Authors string `xml:"d:Authors"`
|
||||
Copyright string `xml:"d:Copyright,omitempty"`
|
||||
Created TypedValue[time.Time] `xml:"d:Created"`
|
||||
Dependencies string `xml:"d:Dependencies"`
|
||||
Description string `xml:"d:Description"`
|
||||
DevelopmentDependency TypedValue[bool] `xml:"d:DevelopmentDependency"`
|
||||
DownloadCount TypedValue[int64] `xml:"d:DownloadCount"`
|
||||
ID string `xml:"d:Id"`
|
||||
IconURL string `xml:"d:IconUrl,omitempty"`
|
||||
Language string `xml:"d:Language,omitempty"`
|
||||
LastUpdated TypedValue[time.Time] `xml:"d:LastUpdated"`
|
||||
LicenseURL string `xml:"d:LicenseUrl,omitempty"`
|
||||
MinClientVersion string `xml:"d:MinClientVersion,omitempty"`
|
||||
NormalizedVersion string `xml:"d:NormalizedVersion"`
|
||||
Owners string `xml:"d:Owners,omitempty"`
|
||||
PackageSize TypedValue[int64] `xml:"d:PackageSize"`
|
||||
ProjectURL string `xml:"d:ProjectUrl,omitempty"`
|
||||
Published TypedValue[time.Time] `xml:"d:Published"`
|
||||
ReleaseNotes string `xml:"d:ReleaseNotes,omitempty"`
|
||||
RequireLicenseAcceptance TypedValue[bool] `xml:"d:RequireLicenseAcceptance"`
|
||||
Tags string `xml:"d:Tags,omitempty"`
|
||||
Title string `xml:"d:Title,omitempty"`
|
||||
Version string `xml:"d:Version"`
|
||||
VersionDownloadCount TypedValue[int64] `xml:"d:VersionDownloadCount"`
|
||||
}
|
||||
|
||||
type FeedEntry struct {
|
||||
XMLName xml.Name `xml:"entry"`
|
||||
Xmlns string `xml:"xmlns,attr,omitempty"`
|
||||
XmlnsD string `xml:"xmlns:d,attr,omitempty"`
|
||||
XmlnsM string `xml:"xmlns:m,attr,omitempty"`
|
||||
Base string `xml:"xml:base,attr,omitempty"`
|
||||
ID string `xml:"id"`
|
||||
Category FeedEntryCategory `xml:"category"`
|
||||
Links []FeedEntryLink `xml:"link"`
|
||||
Title TypedValue[string] `xml:"title"`
|
||||
Updated time.Time `xml:"updated"`
|
||||
Author string `xml:"author>name"`
|
||||
Summary string `xml:"summary"`
|
||||
Properties *FeedEntryProperties `xml:"m:properties"`
|
||||
Content string `xml:",innerxml"`
|
||||
}
|
||||
|
||||
type FeedResponse struct {
|
||||
XMLName xml.Name `xml:"feed"`
|
||||
Xmlns string `xml:"xmlns,attr,omitempty"`
|
||||
XmlnsD string `xml:"xmlns:d,attr,omitempty"`
|
||||
XmlnsM string `xml:"xmlns:m,attr,omitempty"`
|
||||
Base string `xml:"xml:base,attr,omitempty"`
|
||||
ID string `xml:"id"`
|
||||
Title TypedValue[string] `xml:"title"`
|
||||
Updated time.Time `xml:"updated"`
|
||||
Links []FeedEntryLink `xml:"link"`
|
||||
Entries []*FeedEntry `xml:"entry"`
|
||||
Count int64 `xml:"m:count"`
|
||||
}
|
||||
|
||||
func createFeedResponse(l *linkBuilder, totalEntries int64, pds []*packages_model.PackageDescriptor) *FeedResponse {
|
||||
entries := make([]*FeedEntry, 0, len(pds))
|
||||
for _, pd := range pds {
|
||||
entries = append(entries, createEntry(l, pd, false))
|
||||
}
|
||||
|
||||
links := []FeedEntryLink{
|
||||
{Rel: "self", Href: l.Base},
|
||||
}
|
||||
if l.Next != nil {
|
||||
links = append(links, FeedEntryLink{
|
||||
Rel: "next",
|
||||
Href: l.GetNextURL(),
|
||||
})
|
||||
}
|
||||
|
||||
return &FeedResponse{
|
||||
Xmlns: "http://www.w3.org/2005/Atom",
|
||||
Base: l.Base,
|
||||
XmlnsD: "http://schemas.microsoft.com/ado/2007/08/dataservices",
|
||||
XmlnsM: "http://schemas.microsoft.com/ado/2007/08/dataservices/metadata",
|
||||
ID: "http://schemas.datacontract.org/2004/07/",
|
||||
Updated: time.Now(),
|
||||
Links: links,
|
||||
Count: totalEntries,
|
||||
Entries: entries,
|
||||
}
|
||||
}
|
||||
|
||||
func createEntryResponse(l *linkBuilder, pd *packages_model.PackageDescriptor) *FeedEntry {
|
||||
return createEntry(l, pd, true)
|
||||
}
|
||||
|
||||
func createEntry(l *linkBuilder, pd *packages_model.PackageDescriptor, withNamespace bool) *FeedEntry {
|
||||
metadata := pd.Metadata.(*nuget_module.Metadata)
|
||||
|
||||
id := l.GetPackageMetadataURL(pd.Package.Name, pd.Version.Version)
|
||||
|
||||
// Workaround to force a self-closing tag to satisfy XmlReader.IsEmptyElement used by the NuGet client.
|
||||
// https://learn.microsoft.com/en-us/dotnet/api/system.xml.xmlreader.isemptyelement
|
||||
content := `<content type="application/zip" src="` + l.GetPackageDownloadURL(pd.Package.Name, pd.Version.Version) + `"/>`
|
||||
|
||||
createdValue := TypedValue[time.Time]{
|
||||
Type: "Edm.DateTime",
|
||||
Value: pd.Version.CreatedUnix.AsLocalTime(),
|
||||
}
|
||||
|
||||
entry := &FeedEntry{
|
||||
ID: id,
|
||||
Category: FeedEntryCategory{Term: "NuGetGallery.OData.V2FeedPackage", Scheme: "http://schemas.microsoft.com/ado/2007/08/dataservices/scheme"},
|
||||
Links: []FeedEntryLink{
|
||||
{Rel: "self", Href: id},
|
||||
{Rel: "edit", Href: id},
|
||||
},
|
||||
Title: TypedValue[string]{Type: "text", Value: pd.Package.Name},
|
||||
Updated: pd.Version.CreatedUnix.AsLocalTime(),
|
||||
Author: metadata.Authors,
|
||||
Content: content,
|
||||
Properties: &FeedEntryProperties{
|
||||
Authors: metadata.Authors,
|
||||
Copyright: metadata.Copyright,
|
||||
Created: createdValue,
|
||||
Dependencies: buildDependencyString(metadata),
|
||||
Description: metadata.Description,
|
||||
DevelopmentDependency: TypedValue[bool]{Type: "Edm.Boolean", Value: metadata.DevelopmentDependency},
|
||||
DownloadCount: TypedValue[int64]{Type: "Edm.Int64", Value: pd.Version.DownloadCount},
|
||||
ID: pd.Package.Name,
|
||||
IconURL: metadata.IconURL,
|
||||
Language: metadata.Language,
|
||||
LastUpdated: createdValue,
|
||||
LicenseURL: metadata.LicenseURL,
|
||||
MinClientVersion: metadata.MinClientVersion,
|
||||
NormalizedVersion: pd.Version.Version,
|
||||
Owners: metadata.Owners,
|
||||
PackageSize: TypedValue[int64]{Type: "Edm.Int64", Value: pd.CalculateBlobSize()},
|
||||
ProjectURL: metadata.ProjectURL,
|
||||
Published: createdValue,
|
||||
ReleaseNotes: metadata.ReleaseNotes,
|
||||
RequireLicenseAcceptance: TypedValue[bool]{Type: "Edm.Boolean", Value: metadata.RequireLicenseAcceptance},
|
||||
Tags: metadata.Tags,
|
||||
Title: metadata.Title,
|
||||
Version: pd.Version.Version,
|
||||
VersionDownloadCount: TypedValue[int64]{Type: "Edm.Int64", Value: pd.Version.DownloadCount},
|
||||
},
|
||||
}
|
||||
|
||||
if withNamespace {
|
||||
entry.Xmlns = "http://www.w3.org/2005/Atom"
|
||||
entry.Base = l.Base
|
||||
entry.XmlnsD = "http://schemas.microsoft.com/ado/2007/08/dataservices"
|
||||
entry.XmlnsM = "http://schemas.microsoft.com/ado/2007/08/dataservices/metadata"
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
func buildDependencyString(metadata *nuget_module.Metadata) string {
|
||||
var b strings.Builder
|
||||
first := true
|
||||
for group, deps := range metadata.Dependencies {
|
||||
for _, dep := range deps {
|
||||
if !first {
|
||||
b.WriteByte('|')
|
||||
}
|
||||
first = false
|
||||
|
||||
b.WriteString(dep.ID)
|
||||
b.WriteByte(':')
|
||||
b.WriteString(dep.Version)
|
||||
b.WriteByte(':')
|
||||
b.WriteString(group)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
315
routers/api/packages/nuget/api_v3.go
Normal file
315
routers/api/packages/nuget/api_v3.go
Normal file
@@ -0,0 +1,315 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package nuget
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
nuget_module "code.gitea.io/gitea/modules/packages/nuget"
|
||||
|
||||
"golang.org/x/text/collate"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// https://docs.microsoft.com/en-us/nuget/api/service-index#resources
|
||||
type ServiceIndexResponseV3 struct {
|
||||
Version string `json:"version"`
|
||||
Resources []ServiceResource `json:"resources"`
|
||||
}
|
||||
|
||||
// https://docs.microsoft.com/en-us/nuget/api/service-index#resource
|
||||
type ServiceResource struct {
|
||||
ID string `json:"@id"`
|
||||
Type string `json:"@type"`
|
||||
}
|
||||
|
||||
// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#response
|
||||
type RegistrationIndexResponse struct {
|
||||
RegistrationIndexURL string `json:"@id"`
|
||||
Type []string `json:"@type"`
|
||||
Count int `json:"count"`
|
||||
Pages []*RegistrationIndexPage `json:"items"`
|
||||
}
|
||||
|
||||
// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#registration-page-object
|
||||
type RegistrationIndexPage struct {
|
||||
RegistrationPageURL string `json:"@id"`
|
||||
Lower string `json:"lower"`
|
||||
Upper string `json:"upper"`
|
||||
Count int `json:"count"`
|
||||
Items []*RegistrationIndexPageItem `json:"items"`
|
||||
}
|
||||
|
||||
// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#registration-leaf-object-in-a-page
|
||||
type RegistrationIndexPageItem struct {
|
||||
RegistrationLeafURL string `json:"@id"`
|
||||
PackageContentURL string `json:"packageContent"`
|
||||
CatalogEntry *CatalogEntry `json:"catalogEntry"`
|
||||
}
|
||||
|
||||
// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#catalog-entry
|
||||
type CatalogEntry struct {
|
||||
CatalogLeafURL string `json:"@id"`
|
||||
Authors string `json:"authors"`
|
||||
Copyright string `json:"copyright"`
|
||||
DependencyGroups []*PackageDependencyGroup `json:"dependencyGroups"`
|
||||
Description string `json:"description"`
|
||||
IconURL string `json:"iconUrl"`
|
||||
ID string `json:"id"`
|
||||
IsPrerelease bool `json:"isPrerelease"`
|
||||
Language string `json:"language"`
|
||||
LicenseURL string `json:"licenseUrl"`
|
||||
PackageContentURL string `json:"packageContent"`
|
||||
ProjectURL string `json:"projectUrl"`
|
||||
RequireLicenseAcceptance bool `json:"requireLicenseAcceptance"`
|
||||
Summary string `json:"summary"`
|
||||
Tags string `json:"tags"`
|
||||
Version string `json:"version"`
|
||||
ReleaseNotes string `json:"releaseNotes"`
|
||||
Published time.Time `json:"published"`
|
||||
}
|
||||
|
||||
// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#package-dependency-group
|
||||
type PackageDependencyGroup struct {
|
||||
TargetFramework string `json:"targetFramework"`
|
||||
Dependencies []*PackageDependency `json:"dependencies"`
|
||||
}
|
||||
|
||||
// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#package-dependency
|
||||
type PackageDependency struct {
|
||||
ID string `json:"id"`
|
||||
Range string `json:"range"`
|
||||
}
|
||||
|
||||
func createRegistrationIndexResponse(l *linkBuilder, pds []*packages_model.PackageDescriptor) *RegistrationIndexResponse {
|
||||
sort.Slice(pds, func(i, j int) bool {
|
||||
return pds[i].SemVer.LessThan(pds[j].SemVer)
|
||||
})
|
||||
|
||||
items := make([]*RegistrationIndexPageItem, 0, len(pds))
|
||||
for _, p := range pds {
|
||||
items = append(items, createRegistrationIndexPageItem(l, p))
|
||||
}
|
||||
|
||||
return &RegistrationIndexResponse{
|
||||
RegistrationIndexURL: l.GetRegistrationIndexURL(pds[0].Package.Name),
|
||||
Type: []string{"catalog:CatalogRoot", "PackageRegistration", "catalog:Permalink"},
|
||||
Count: 1,
|
||||
Pages: []*RegistrationIndexPage{
|
||||
{
|
||||
RegistrationPageURL: l.GetRegistrationIndexURL(pds[0].Package.Name),
|
||||
Count: len(pds),
|
||||
Lower: pds[0].Version.Version,
|
||||
Upper: pds[len(pds)-1].Version.Version,
|
||||
Items: items,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createRegistrationIndexPageItem(l *linkBuilder, pd *packages_model.PackageDescriptor) *RegistrationIndexPageItem {
|
||||
metadata := pd.Metadata.(*nuget_module.Metadata)
|
||||
|
||||
return &RegistrationIndexPageItem{
|
||||
RegistrationLeafURL: l.GetRegistrationLeafURL(pd.Package.Name, pd.Version.Version),
|
||||
PackageContentURL: l.GetPackageDownloadURL(pd.Package.Name, pd.Version.Version),
|
||||
CatalogEntry: &CatalogEntry{
|
||||
CatalogLeafURL: l.GetRegistrationLeafURL(pd.Package.Name, pd.Version.Version),
|
||||
Authors: metadata.Authors,
|
||||
Copyright: metadata.Copyright,
|
||||
DependencyGroups: createDependencyGroups(pd),
|
||||
Description: metadata.Description,
|
||||
IconURL: metadata.IconURL,
|
||||
ID: pd.Package.Name,
|
||||
IsPrerelease: pd.Version.IsPrerelease(),
|
||||
Language: metadata.Language,
|
||||
LicenseURL: metadata.LicenseURL,
|
||||
PackageContentURL: l.GetPackageDownloadURL(pd.Package.Name, pd.Version.Version),
|
||||
ProjectURL: metadata.ProjectURL,
|
||||
RequireLicenseAcceptance: metadata.RequireLicenseAcceptance,
|
||||
Summary: metadata.Summary,
|
||||
Tags: metadata.Tags,
|
||||
Version: pd.Version.Version,
|
||||
ReleaseNotes: metadata.ReleaseNotes,
|
||||
Published: pd.Version.CreatedUnix.AsLocalTime(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createDependencyGroups(pd *packages_model.PackageDescriptor) []*PackageDependencyGroup {
|
||||
metadata := pd.Metadata.(*nuget_module.Metadata)
|
||||
|
||||
dependencyGroups := make([]*PackageDependencyGroup, 0, len(metadata.Dependencies))
|
||||
for k, v := range metadata.Dependencies {
|
||||
dependencies := make([]*PackageDependency, 0, len(v))
|
||||
for _, dep := range v {
|
||||
dependencies = append(dependencies, &PackageDependency{
|
||||
ID: dep.ID,
|
||||
Range: dep.Version,
|
||||
})
|
||||
}
|
||||
|
||||
dependencyGroups = append(dependencyGroups, &PackageDependencyGroup{
|
||||
TargetFramework: k,
|
||||
Dependencies: dependencies,
|
||||
})
|
||||
}
|
||||
return dependencyGroups
|
||||
}
|
||||
|
||||
// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#registration-leaf
|
||||
type RegistrationLeafResponse struct {
|
||||
RegistrationLeafURL string `json:"@id"`
|
||||
Type []string `json:"@type"`
|
||||
PackageContentURL string `json:"packageContent"`
|
||||
RegistrationIndexURL string `json:"registration"`
|
||||
CatalogEntry CatalogEntry `json:"catalogEntry"`
|
||||
}
|
||||
|
||||
func createRegistrationLeafResponse(l *linkBuilder, pd *packages_model.PackageDescriptor) *RegistrationLeafResponse {
|
||||
registrationLeafURL := l.GetRegistrationLeafURL(pd.Package.Name, pd.Version.Version)
|
||||
packageDownloadURL := l.GetPackageDownloadURL(pd.Package.Name, pd.Version.Version)
|
||||
metadata := pd.Metadata.(*nuget_module.Metadata)
|
||||
return &RegistrationLeafResponse{
|
||||
RegistrationLeafURL: registrationLeafURL,
|
||||
RegistrationIndexURL: l.GetRegistrationIndexURL(pd.Package.Name),
|
||||
PackageContentURL: packageDownloadURL,
|
||||
Type: []string{"Package", "http://schema.nuget.org/catalog#Permalink"},
|
||||
CatalogEntry: CatalogEntry{
|
||||
CatalogLeafURL: registrationLeafURL,
|
||||
Authors: metadata.Authors,
|
||||
Copyright: metadata.Copyright,
|
||||
DependencyGroups: createDependencyGroups(pd),
|
||||
Description: metadata.Description,
|
||||
IconURL: metadata.IconURL,
|
||||
ID: pd.Package.Name,
|
||||
IsPrerelease: pd.Version.IsPrerelease(),
|
||||
Language: metadata.Language,
|
||||
LicenseURL: metadata.LicenseURL,
|
||||
PackageContentURL: packageDownloadURL,
|
||||
ProjectURL: metadata.ProjectURL,
|
||||
RequireLicenseAcceptance: metadata.RequireLicenseAcceptance,
|
||||
Summary: metadata.Summary,
|
||||
Tags: metadata.Tags,
|
||||
Version: pd.Version.Version,
|
||||
ReleaseNotes: metadata.ReleaseNotes,
|
||||
Published: pd.Version.CreatedUnix.AsLocalTime(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// https://docs.microsoft.com/en-us/nuget/api/package-base-address-resource#response
|
||||
type PackageVersionsResponse struct {
|
||||
Versions []string `json:"versions"`
|
||||
}
|
||||
|
||||
func createPackageVersionsResponse(pvs []*packages_model.PackageVersion) *PackageVersionsResponse {
|
||||
versions := make([]string, 0, len(pvs))
|
||||
for _, pv := range pvs {
|
||||
versions = append(versions, pv.Version)
|
||||
}
|
||||
|
||||
return &PackageVersionsResponse{
|
||||
Versions: versions,
|
||||
}
|
||||
}
|
||||
|
||||
// https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource#response
|
||||
type SearchResultResponse struct {
|
||||
TotalHits int64 `json:"totalHits"`
|
||||
Data []*SearchResult `json:"data"`
|
||||
}
|
||||
|
||||
// https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource#search-result
|
||||
type SearchResult struct {
|
||||
Authors string `json:"authors"`
|
||||
Copyright string `json:"copyright"`
|
||||
DependencyGroups []*PackageDependencyGroup `json:"dependencyGroups"`
|
||||
Description string `json:"description"`
|
||||
IconURL string `json:"iconUrl"`
|
||||
ID string `json:"id"`
|
||||
IsPrerelease bool `json:"isPrerelease"`
|
||||
Language string `json:"language"`
|
||||
LicenseURL string `json:"licenseUrl"`
|
||||
ProjectURL string `json:"projectUrl"`
|
||||
RequireLicenseAcceptance bool `json:"requireLicenseAcceptance"`
|
||||
Summary string `json:"summary"`
|
||||
Tags string `json:"tags"`
|
||||
Title string `json:"title"`
|
||||
TotalDownloads int64 `json:"totalDownloads"`
|
||||
Version string `json:"version"`
|
||||
Versions []*SearchResultVersion `json:"versions"`
|
||||
RegistrationIndexURL string `json:"registration"`
|
||||
}
|
||||
|
||||
// https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource#search-result
|
||||
type SearchResultVersion struct {
|
||||
RegistrationLeafURL string `json:"@id"`
|
||||
Version string `json:"version"`
|
||||
Downloads int64 `json:"downloads"`
|
||||
}
|
||||
|
||||
func createSearchResultResponse(l *linkBuilder, totalHits int64, pds []*packages_model.PackageDescriptor) *SearchResultResponse {
|
||||
grouped := make(map[string][]*packages_model.PackageDescriptor)
|
||||
for _, pd := range pds {
|
||||
grouped[pd.Package.Name] = append(grouped[pd.Package.Name], pd)
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(grouped))
|
||||
for key := range grouped {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
collate.New(language.English, collate.IgnoreCase).SortStrings(keys)
|
||||
|
||||
data := make([]*SearchResult, 0, len(pds))
|
||||
for _, key := range keys {
|
||||
data = append(data, createSearchResult(l, grouped[key]))
|
||||
}
|
||||
|
||||
return &SearchResultResponse{
|
||||
TotalHits: totalHits,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func createSearchResult(l *linkBuilder, pds []*packages_model.PackageDescriptor) *SearchResult {
|
||||
latest := pds[0]
|
||||
versions := make([]*SearchResultVersion, 0, len(pds))
|
||||
totalDownloads := int64(0)
|
||||
for _, pd := range pds {
|
||||
if latest.SemVer.LessThan(pd.SemVer) {
|
||||
latest = pd
|
||||
}
|
||||
totalDownloads += pd.Version.DownloadCount
|
||||
versions = append(versions, &SearchResultVersion{
|
||||
RegistrationLeafURL: l.GetRegistrationLeafURL(pd.Package.Name, pd.Version.Version),
|
||||
Version: pd.Version.Version,
|
||||
})
|
||||
}
|
||||
|
||||
metadata := latest.Metadata.(*nuget_module.Metadata)
|
||||
|
||||
return &SearchResult{
|
||||
Authors: metadata.Authors,
|
||||
Copyright: metadata.Copyright,
|
||||
Description: metadata.Description,
|
||||
DependencyGroups: createDependencyGroups(latest),
|
||||
IconURL: metadata.IconURL,
|
||||
ID: latest.Package.Name,
|
||||
IsPrerelease: latest.Version.IsPrerelease(),
|
||||
Language: metadata.Language,
|
||||
LicenseURL: metadata.LicenseURL,
|
||||
ProjectURL: metadata.ProjectURL,
|
||||
RequireLicenseAcceptance: metadata.RequireLicenseAcceptance,
|
||||
Summary: metadata.Summary,
|
||||
Tags: metadata.Tags,
|
||||
Title: metadata.Title,
|
||||
TotalDownloads: totalDownloads,
|
||||
Version: latest.Version.Version,
|
||||
Versions: versions,
|
||||
RegistrationIndexURL: l.GetRegistrationIndexURL(latest.Package.Name),
|
||||
}
|
||||
}
|
||||
48
routers/api/packages/nuget/auth.go
Normal file
48
routers/api/packages/nuget/auth.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package nuget
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/services/auth"
|
||||
)
|
||||
|
||||
var _ auth.Method = &Auth{}
|
||||
|
||||
type Auth struct{}
|
||||
|
||||
func (a *Auth) Name() string {
|
||||
return "nuget"
|
||||
}
|
||||
|
||||
// https://docs.microsoft.com/en-us/nuget/api/package-publish-resource#request-parameters
|
||||
func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataStore, sess auth.SessionStore) (*user_model.User, error) {
|
||||
token, err := auth_model.GetAccessTokenBySHA(req.Context(), req.Header.Get("X-NuGet-ApiKey"))
|
||||
if err != nil {
|
||||
if !(auth_model.IsErrAccessTokenNotExist(err) || auth_model.IsErrAccessTokenEmpty(err)) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
u, err := user_model.GetUserByID(req.Context(), token.UID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token.UpdatedUnix = timeutil.TimeStampNow()
|
||||
if err := auth_model.UpdateAccessToken(req.Context(), token); err != nil {
|
||||
log.Error("UpdateAccessToken: %v", err)
|
||||
}
|
||||
|
||||
store.GetData()["IsApiToken"] = true
|
||||
store.GetData()["ApiToken"] = token
|
||||
|
||||
return u, nil
|
||||
}
|
||||
52
routers/api/packages/nuget/links.go
Normal file
52
routers/api/packages/nuget/links.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package nuget
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type nextOptions struct {
|
||||
Path string
|
||||
Query url.Values
|
||||
}
|
||||
|
||||
type linkBuilder struct {
|
||||
Base string
|
||||
Next *nextOptions
|
||||
}
|
||||
|
||||
// GetRegistrationIndexURL builds the registration index url
|
||||
func (l *linkBuilder) GetRegistrationIndexURL(id string) string {
|
||||
return fmt.Sprintf("%s/registration/%s/index.json", l.Base, id)
|
||||
}
|
||||
|
||||
// GetRegistrationLeafURL builds the registration leaf url
|
||||
func (l *linkBuilder) GetRegistrationLeafURL(id, version string) string {
|
||||
return fmt.Sprintf("%s/registration/%s/%s.json", l.Base, id, version)
|
||||
}
|
||||
|
||||
// GetPackageDownloadURL builds the download url
|
||||
func (l *linkBuilder) GetPackageDownloadURL(id, version string) string {
|
||||
return fmt.Sprintf("%s/package/%s/%s/%s.%s.nupkg", l.Base, id, version, id, version)
|
||||
}
|
||||
|
||||
// GetPackageMetadataURL builds the package metadata url
|
||||
func (l *linkBuilder) GetPackageMetadataURL(id, version string) string {
|
||||
return fmt.Sprintf("%s/Packages(Id='%s',Version='%s')", l.Base, id, version)
|
||||
}
|
||||
|
||||
func (l *linkBuilder) GetNextURL() string {
|
||||
u, _ := url.Parse(l.Base)
|
||||
u = u.JoinPath(l.Next.Path)
|
||||
q := u.Query()
|
||||
for k, vs := range l.Next.Query {
|
||||
for _, v := range vs {
|
||||
q.Add(k, v)
|
||||
}
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
705
routers/api/packages/nuget/nuget.go
Normal file
705
routers/api/packages/nuget/nuget.go
Normal file
@@ -0,0 +1,705 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package nuget
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
nuget_model "code.gitea.io/gitea/models/packages/nuget"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
packages_module "code.gitea.io/gitea/modules/packages"
|
||||
nuget_module "code.gitea.io/gitea/modules/packages/nuget"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/routers/api/packages/helper"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
ctx.JSON(status, map[string]string{
|
||||
"Message": message,
|
||||
})
|
||||
}
|
||||
|
||||
func xmlResponse(ctx *context.Context, status int, obj any) { //nolint:unparam // status is always StatusOK
|
||||
ctx.Resp.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")
|
||||
ctx.Resp.WriteHeader(status)
|
||||
_, _ = ctx.Resp.Write([]byte(xml.Header))
|
||||
_ = xml.NewEncoder(ctx.Resp).Encode(obj)
|
||||
}
|
||||
|
||||
// https://github.com/NuGet/NuGet.Client/blob/dev/src/NuGet.Core/NuGet.Protocol/LegacyFeed/V2FeedQueryBuilder.cs
|
||||
func ServiceIndexV2(ctx *context.Context) {
|
||||
base := setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget"
|
||||
|
||||
xmlResponse(ctx, http.StatusOK, &ServiceIndexResponseV2{
|
||||
Base: base,
|
||||
Xmlns: "http://www.w3.org/2007/app",
|
||||
XmlnsAtom: "http://www.w3.org/2005/Atom",
|
||||
Workspace: ServiceWorkspace{
|
||||
Title: AtomTitle{
|
||||
Type: "text",
|
||||
Text: "Default",
|
||||
},
|
||||
Collection: ServiceCollection{
|
||||
Href: "Packages",
|
||||
Title: AtomTitle{
|
||||
Type: "text",
|
||||
Text: "Packages",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// https://docs.microsoft.com/en-us/nuget/api/service-index
|
||||
func ServiceIndexV3(ctx *context.Context) {
|
||||
root := setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget"
|
||||
|
||||
ctx.JSON(http.StatusOK, &ServiceIndexResponseV3{
|
||||
Version: "3.0.0",
|
||||
Resources: []ServiceResource{
|
||||
{ID: root + "/query", Type: "SearchQueryService"},
|
||||
{ID: root + "/query", Type: "SearchQueryService/3.0.0-beta"},
|
||||
{ID: root + "/query", Type: "SearchQueryService/3.0.0-rc"},
|
||||
{ID: root + "/registration", Type: "RegistrationsBaseUrl"},
|
||||
{ID: root + "/registration", Type: "RegistrationsBaseUrl/3.0.0-beta"},
|
||||
{ID: root + "/registration", Type: "RegistrationsBaseUrl/3.0.0-rc"},
|
||||
{ID: root + "/package", Type: "PackageBaseAddress/3.0.0"},
|
||||
{ID: root, Type: "PackagePublish/2.0.0"},
|
||||
{ID: root + "/symbolpackage", Type: "SymbolPackagePublish/4.9.0"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// https://github.com/NuGet/NuGet.Client/blob/dev/src/NuGet.Core/NuGet.Protocol/LegacyFeed/LegacyFeedCapabilityResourceV2Feed.cs
|
||||
func FeedCapabilityResource(ctx *context.Context) {
|
||||
xmlResponse(ctx, http.StatusOK, Metadata)
|
||||
}
|
||||
|
||||
var (
|
||||
searchTermExtract = regexp.MustCompile(`'([^']+)'`)
|
||||
searchTermExact = regexp.MustCompile(`\s+eq\s+'`)
|
||||
)
|
||||
|
||||
func getSearchTerm(ctx *context.Context) packages_model.SearchValue {
|
||||
searchTerm := strings.Trim(ctx.FormTrim("searchTerm"), "'")
|
||||
if searchTerm != "" {
|
||||
return packages_model.SearchValue{
|
||||
Value: searchTerm,
|
||||
ExactMatch: false,
|
||||
}
|
||||
}
|
||||
|
||||
// $filter contains a query like:
|
||||
// (((Id ne null) and substringof('microsoft',tolower(Id)))
|
||||
// https://www.odata.org/documentation/odata-version-2-0/uri-conventions/ section 4.5
|
||||
// We don't support these queries, just extract the search term.
|
||||
filter := ctx.FormTrim("$filter")
|
||||
match := searchTermExtract.FindStringSubmatch(filter)
|
||||
if len(match) == 2 {
|
||||
return packages_model.SearchValue{
|
||||
Value: strings.TrimSpace(match[1]),
|
||||
ExactMatch: searchTermExact.MatchString(filter),
|
||||
}
|
||||
}
|
||||
|
||||
return packages_model.SearchValue{}
|
||||
}
|
||||
|
||||
// https://github.com/NuGet/NuGet.Client/blob/dev/src/NuGet.Core/NuGet.Protocol/LegacyFeed/V2FeedQueryBuilder.cs
|
||||
func SearchServiceV2(ctx *context.Context) {
|
||||
skip, take := ctx.FormInt("$skip"), ctx.FormInt("$top")
|
||||
paginator := db.NewAbsoluteListOptions(skip, take)
|
||||
|
||||
pvs, total, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Type: packages_model.TypeNuGet,
|
||||
Name: getSearchTerm(ctx),
|
||||
IsInternal: optional.Some(false),
|
||||
Paginator: paginator,
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
skip, take = paginator.GetSkipTake()
|
||||
|
||||
var next *nextOptions
|
||||
if len(pvs) == take {
|
||||
next = &nextOptions{
|
||||
Path: "Search()",
|
||||
Query: url.Values{},
|
||||
}
|
||||
searchTerm := ctx.FormTrim("searchTerm")
|
||||
if searchTerm != "" {
|
||||
next.Query.Set("searchTerm", searchTerm)
|
||||
}
|
||||
filter := ctx.FormTrim("$filter")
|
||||
if filter != "" {
|
||||
next.Query.Set("$filter", filter)
|
||||
}
|
||||
next.Query.Set("$skip", strconv.Itoa(skip+take))
|
||||
next.Query.Set("$top", strconv.Itoa(take))
|
||||
}
|
||||
|
||||
resp := createFeedResponse(
|
||||
&linkBuilder{Base: setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget", Next: next},
|
||||
total,
|
||||
pds,
|
||||
)
|
||||
|
||||
xmlResponse(ctx, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// http://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions-complete.html#_Toc453752351
|
||||
func SearchServiceV2Count(ctx *context.Context) {
|
||||
count, err := nuget_model.CountPackages(ctx, &packages_model.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Name: getSearchTerm(ctx),
|
||||
IsInternal: optional.Some(false),
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.PlainText(http.StatusOK, strconv.FormatInt(count, 10))
|
||||
}
|
||||
|
||||
// https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource#search-for-packages
|
||||
func SearchServiceV3(ctx *context.Context) {
|
||||
pvs, count, err := nuget_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Name: packages_model.SearchValue{Value: ctx.FormTrim("q")},
|
||||
IsInternal: optional.Some(false),
|
||||
Paginator: db.NewAbsoluteListOptions(
|
||||
ctx.FormInt("skip"),
|
||||
ctx.FormInt("take"),
|
||||
),
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp := createSearchResultResponse(
|
||||
&linkBuilder{Base: setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget"},
|
||||
count,
|
||||
pds,
|
||||
)
|
||||
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#registration-index
|
||||
func RegistrationIndex(ctx *context.Context) {
|
||||
packageName := ctx.PathParam("id")
|
||||
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNuGet, packageName)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pvs) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
|
||||
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp := createRegistrationIndexResponse(
|
||||
&linkBuilder{Base: setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget"},
|
||||
pds,
|
||||
)
|
||||
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// https://github.com/NuGet/NuGet.Client/blob/dev/src/NuGet.Core/NuGet.Protocol/LegacyFeed/V2FeedQueryBuilder.cs
|
||||
func RegistrationLeafV2(ctx *context.Context) {
|
||||
packageName := ctx.PathParam("id")
|
||||
packageVersion := ctx.PathParam("version")
|
||||
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeNuGet, packageName, packageVersion)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
pd, err := packages_model.GetPackageDescriptor(ctx, pv)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp := createEntryResponse(
|
||||
&linkBuilder{Base: setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget"},
|
||||
pd,
|
||||
)
|
||||
|
||||
xmlResponse(ctx, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#registration-leaf
|
||||
func RegistrationLeafV3(ctx *context.Context) {
|
||||
packageName := ctx.PathParam("id")
|
||||
packageVersion := strings.TrimSuffix(ctx.PathParam("version"), ".json")
|
||||
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeNuGet, packageName, packageVersion)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
pd, err := packages_model.GetPackageDescriptor(ctx, pv)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp := createRegistrationLeafResponse(
|
||||
&linkBuilder{Base: setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget"},
|
||||
pd,
|
||||
)
|
||||
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// https://github.com/NuGet/NuGet.Client/blob/dev/src/NuGet.Core/NuGet.Protocol/LegacyFeed/V2FeedQueryBuilder.cs
|
||||
func EnumeratePackageVersionsV2(ctx *context.Context) {
|
||||
packageName := strings.Trim(ctx.FormTrim("id"), "'")
|
||||
|
||||
skip, take := ctx.FormInt("$skip"), ctx.FormInt("$top")
|
||||
paginator := db.NewAbsoluteListOptions(skip, take)
|
||||
|
||||
pvs, total, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Type: packages_model.TypeNuGet,
|
||||
Name: packages_model.SearchValue{
|
||||
ExactMatch: true,
|
||||
Value: packageName,
|
||||
},
|
||||
IsInternal: optional.Some(false),
|
||||
Paginator: paginator,
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
skip, take = paginator.GetSkipTake()
|
||||
|
||||
var next *nextOptions
|
||||
if len(pvs) == take {
|
||||
next = &nextOptions{
|
||||
Path: "FindPackagesById()",
|
||||
Query: url.Values{},
|
||||
}
|
||||
next.Query.Set("id", packageName)
|
||||
next.Query.Set("$skip", strconv.Itoa(skip+take))
|
||||
next.Query.Set("$top", strconv.Itoa(take))
|
||||
}
|
||||
|
||||
resp := createFeedResponse(
|
||||
&linkBuilder{Base: setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget", Next: next},
|
||||
total,
|
||||
pds,
|
||||
)
|
||||
|
||||
xmlResponse(ctx, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// http://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions-complete.html#_Toc453752351
|
||||
func EnumeratePackageVersionsV2Count(ctx *context.Context) {
|
||||
count, err := packages_model.CountVersions(ctx, &packages_model.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Type: packages_model.TypeNuGet,
|
||||
Name: packages_model.SearchValue{
|
||||
ExactMatch: true,
|
||||
Value: strings.Trim(ctx.FormTrim("id"), "'"),
|
||||
},
|
||||
IsInternal: optional.Some(false),
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.PlainText(http.StatusOK, strconv.FormatInt(count, 10))
|
||||
}
|
||||
|
||||
// https://docs.microsoft.com/en-us/nuget/api/package-base-address-resource#enumerate-package-versions
|
||||
func EnumeratePackageVersionsV3(ctx *context.Context) {
|
||||
packageName := ctx.PathParam("id")
|
||||
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNuGet, packageName)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pvs) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp := createPackageVersionsResponse(pvs)
|
||||
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// https://learn.microsoft.com/en-us/nuget/api/package-base-address-resource#download-package-manifest-nuspec
|
||||
// https://learn.microsoft.com/en-us/nuget/api/package-base-address-resource#download-package-content-nupkg
|
||||
func DownloadPackageFile(ctx *context.Context) {
|
||||
packageName := ctx.PathParam("id")
|
||||
packageVersion := ctx.PathParam("version")
|
||||
filename := ctx.PathParam("filename")
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion(
|
||||
ctx,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeNuGet,
|
||||
Name: packageName,
|
||||
Version: packageVersion,
|
||||
},
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: filename,
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(err, packages_model.ErrPackageFileNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
|
||||
// UploadPackage creates a new package with the metadata contained in the uploaded nupgk file
|
||||
// https://docs.microsoft.com/en-us/nuget/api/package-publish-resource#push-a-package
|
||||
func UploadPackage(ctx *context.Context) {
|
||||
np, buf, closables := processUploadedFile(ctx, nuget_module.DependencyPackage)
|
||||
defer func() {
|
||||
for _, c := range closables {
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
if np == nil {
|
||||
return
|
||||
}
|
||||
|
||||
pv, _, err := packages_service.CreatePackageAndAddFile(
|
||||
ctx,
|
||||
&packages_service.PackageCreationInfo{
|
||||
PackageInfo: packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeNuGet,
|
||||
Name: np.ID,
|
||||
Version: np.Version,
|
||||
},
|
||||
SemverCompatible: true,
|
||||
Creator: ctx.Doer,
|
||||
Metadata: np.Metadata,
|
||||
},
|
||||
&packages_service.PackageFileCreationInfo{
|
||||
PackageFileInfo: packages_service.PackageFileInfo{
|
||||
Filename: strings.ToLower(fmt.Sprintf("%s.%s.nupkg", np.ID, np.Version)),
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Data: buf,
|
||||
IsLead: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case packages_model.ErrDuplicatePackageVersion:
|
||||
apiError(ctx, http.StatusConflict, err)
|
||||
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
default:
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
nuspecBuf, err := packages_module.CreateHashedBufferFromReaderWithSize(np.NuspecContent, np.NuspecContent.Len())
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer nuspecBuf.Close()
|
||||
|
||||
_, err = packages_service.AddFileToPackageVersionInternal(
|
||||
ctx,
|
||||
pv,
|
||||
&packages_service.PackageFileCreationInfo{
|
||||
PackageFileInfo: packages_service.PackageFileInfo{
|
||||
Filename: strings.ToLower(np.ID + ".nuspec"),
|
||||
},
|
||||
Data: nuspecBuf,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
default:
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusCreated)
|
||||
}
|
||||
|
||||
// UploadSymbolPackage adds a symbol package to an existing package
|
||||
// https://docs.microsoft.com/en-us/nuget/api/symbol-package-publish-resource
|
||||
func UploadSymbolPackage(ctx *context.Context) {
|
||||
np, buf, closables := processUploadedFile(ctx, nuget_module.SymbolsPackage)
|
||||
defer func() {
|
||||
for _, c := range closables {
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
if np == nil {
|
||||
return
|
||||
}
|
||||
|
||||
pdbs, err := nuget_module.ExtractPortablePdb(buf, buf.Size())
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
defer pdbs.Close()
|
||||
|
||||
if _, err := buf.Seek(0, io.SeekStart); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
pi := &packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeNuGet,
|
||||
Name: np.ID,
|
||||
Version: np.Version,
|
||||
}
|
||||
|
||||
_, err = packages_service.AddFileToExistingPackage(
|
||||
ctx,
|
||||
pi,
|
||||
&packages_service.PackageFileCreationInfo{
|
||||
PackageFileInfo: packages_service.PackageFileInfo{
|
||||
Filename: strings.ToLower(fmt.Sprintf("%s.%s.snupkg", np.ID, np.Version)),
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Data: buf,
|
||||
IsLead: false,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case packages_model.ErrPackageNotExist:
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
case packages_model.ErrDuplicatePackageFile:
|
||||
apiError(ctx, http.StatusConflict, err)
|
||||
case packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
default:
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for _, pdb := range pdbs {
|
||||
_, err := packages_service.AddFileToExistingPackage(
|
||||
ctx,
|
||||
pi,
|
||||
&packages_service.PackageFileCreationInfo{
|
||||
PackageFileInfo: packages_service.PackageFileInfo{
|
||||
Filename: strings.ToLower(pdb.Name),
|
||||
CompositeKey: strings.ToLower(pdb.ID),
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Data: pdb.Content,
|
||||
IsLead: false,
|
||||
Properties: map[string]string{
|
||||
nuget_module.PropertySymbolID: strings.ToLower(pdb.ID),
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case packages_model.ErrDuplicatePackageFile:
|
||||
apiError(ctx, http.StatusConflict, err)
|
||||
case packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
default:
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusCreated)
|
||||
}
|
||||
|
||||
func processUploadedFile(ctx *context.Context, expectedType nuget_module.PackageType) (*nuget_module.Package, *packages_module.HashedBuffer, []io.Closer) {
|
||||
closables := make([]io.Closer, 0, 2)
|
||||
|
||||
upload, needToClose, err := ctx.UploadStream()
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
return nil, nil, closables
|
||||
}
|
||||
|
||||
if needToClose {
|
||||
closables = append(closables, upload)
|
||||
}
|
||||
|
||||
buf, err := packages_module.CreateHashedBufferFromReader(upload)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return nil, nil, closables
|
||||
}
|
||||
closables = append(closables, buf)
|
||||
|
||||
np, err := nuget_module.ParsePackageMetaData(buf, buf.Size())
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return nil, nil, closables
|
||||
}
|
||||
if np.PackageType != expectedType {
|
||||
apiError(ctx, http.StatusBadRequest, errors.New("unexpected package type"))
|
||||
return nil, nil, closables
|
||||
}
|
||||
if _, err := buf.Seek(0, io.SeekStart); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return nil, nil, closables
|
||||
}
|
||||
return np, buf, closables
|
||||
}
|
||||
|
||||
// https://github.com/dotnet/symstore/blob/main/docs/specs/Simple_Symbol_Query_Protocol.md#request
|
||||
func DownloadSymbolFile(ctx *context.Context) {
|
||||
filename := ctx.PathParam("filename")
|
||||
guid := ctx.PathParam("guid")[:32]
|
||||
filename2 := ctx.PathParam("filename2")
|
||||
|
||||
if filename != filename2 {
|
||||
apiError(ctx, http.StatusBadRequest, nil)
|
||||
return
|
||||
}
|
||||
|
||||
pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
PackageType: packages_model.TypeNuGet,
|
||||
Query: filename,
|
||||
Properties: map[string]string{
|
||||
nuget_module.PropertySymbolID: strings.ToLower(guid),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pfs) != 1 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownload(ctx, pfs[0], ctx.Req.Method)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(err, packages_model.ErrPackageFileNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
|
||||
// DeletePackage hard deletes the package
|
||||
// https://docs.microsoft.com/en-us/nuget/api/package-publish-resource#delete-a-package
|
||||
func DeletePackage(ctx *context.Context) {
|
||||
packageName := ctx.PathParam("id")
|
||||
packageVersion := ctx.PathParam("version")
|
||||
|
||||
err := packages_service.RemovePackageVersionByNameAndVersion(
|
||||
ctx,
|
||||
ctx.Doer,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeNuGet,
|
||||
Name: packageName,
|
||||
Version: packageVersion,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
280
routers/api/packages/pub/pub.go
Normal file
280
routers/api/packages/pub/pub.go
Normal file
@@ -0,0 +1,280 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pub
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
packages_module "code.gitea.io/gitea/modules/packages"
|
||||
pub_module "code.gitea.io/gitea/modules/packages/pub"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/routers/api/packages/helper"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
)
|
||||
|
||||
func jsonResponse(ctx *context.Context, status int, obj any) {
|
||||
resp := ctx.Resp
|
||||
resp.Header().Set("Content-Type", "application/vnd.pub.v2+json")
|
||||
resp.WriteHeader(status)
|
||||
_ = json.NewEncoder(resp).Encode(obj)
|
||||
}
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
type Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
type ErrorWrapper struct {
|
||||
Error Error `json:"error"`
|
||||
}
|
||||
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
jsonResponse(ctx, status, ErrorWrapper{
|
||||
Error: Error{
|
||||
Code: http.StatusText(status),
|
||||
Message: message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type packageVersions struct {
|
||||
Name string `json:"name"`
|
||||
Latest *versionMetadata `json:"latest"`
|
||||
Versions []*versionMetadata `json:"versions"`
|
||||
}
|
||||
|
||||
type versionMetadata struct {
|
||||
Version string `json:"version"`
|
||||
ArchiveURL string `json:"archive_url"`
|
||||
Published time.Time `json:"published"`
|
||||
Pubspec any `json:"pubspec,omitempty"`
|
||||
}
|
||||
|
||||
func packageDescriptorToMetadata(baseURL string, pd *packages_model.PackageDescriptor) *versionMetadata {
|
||||
return &versionMetadata{
|
||||
Version: pd.Version.Version,
|
||||
ArchiveURL: fmt.Sprintf("%s/files/%s.tar.gz", baseURL, url.PathEscape(pd.Version.Version)),
|
||||
Published: pd.Version.CreatedUnix.AsLocalTime(),
|
||||
Pubspec: pd.Metadata.(*pub_module.Metadata).Pubspec,
|
||||
}
|
||||
}
|
||||
|
||||
func baseURL(ctx *context.Context) string {
|
||||
return setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/pub/api/packages"
|
||||
}
|
||||
|
||||
// https://github.com/dart-lang/pub/blob/master/doc/repository-spec-v2.md#list-all-versions-of-a-package
|
||||
func EnumeratePackageVersions(ctx *context.Context) {
|
||||
packageName := ctx.PathParam("id")
|
||||
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypePub, packageName)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pvs) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
|
||||
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
sort.Slice(pds, func(i, j int) bool {
|
||||
return pds[i].SemVer.LessThan(pds[j].SemVer)
|
||||
})
|
||||
|
||||
baseURL := fmt.Sprintf("%s/%s", baseURL(ctx), url.PathEscape(pds[0].Package.Name))
|
||||
|
||||
versions := make([]*versionMetadata, 0, len(pds))
|
||||
for _, pd := range pds {
|
||||
versions = append(versions, packageDescriptorToMetadata(baseURL, pd))
|
||||
}
|
||||
|
||||
jsonResponse(ctx, http.StatusOK, &packageVersions{
|
||||
Name: pds[0].Package.Name,
|
||||
Latest: packageDescriptorToMetadata(baseURL, pds[0]),
|
||||
Versions: versions,
|
||||
})
|
||||
}
|
||||
|
||||
// https://github.com/dart-lang/pub/blob/master/doc/repository-spec-v2.md#deprecated-inspect-a-specific-version-of-a-package
|
||||
func PackageVersionMetadata(ctx *context.Context) {
|
||||
packageName := ctx.PathParam("id")
|
||||
packageVersion := ctx.PathParam("version")
|
||||
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypePub, packageName, packageVersion)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
pd, err := packages_model.GetPackageDescriptor(ctx, pv)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(ctx, http.StatusOK, packageDescriptorToMetadata(
|
||||
fmt.Sprintf("%s/%s", baseURL(ctx), url.PathEscape(pd.Package.Name)),
|
||||
pd,
|
||||
))
|
||||
}
|
||||
|
||||
// https://github.com/dart-lang/pub/blob/master/doc/repository-spec-v2.md#publishing-packages
|
||||
func RequestUpload(ctx *context.Context) {
|
||||
type UploadRequest struct {
|
||||
URL string `json:"url"`
|
||||
Fields map[string]string `json:"fields"`
|
||||
}
|
||||
|
||||
jsonResponse(ctx, http.StatusOK, UploadRequest{
|
||||
URL: baseURL(ctx) + "/versions/new/upload",
|
||||
Fields: make(map[string]string),
|
||||
})
|
||||
}
|
||||
|
||||
// https://github.com/dart-lang/pub/blob/master/doc/repository-spec-v2.md#publishing-packages
|
||||
func UploadPackageFile(ctx *context.Context) {
|
||||
file, _, err := ctx.Req.FormFile("file")
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
buf, err := packages_module.CreateHashedBufferFromReader(file)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer buf.Close()
|
||||
|
||||
pck, err := pub_module.ParsePackage(buf)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := buf.Seek(0, io.SeekStart); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
_, _, err = packages_service.CreatePackageAndAddFile(
|
||||
ctx,
|
||||
&packages_service.PackageCreationInfo{
|
||||
PackageInfo: packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypePub,
|
||||
Name: pck.Name,
|
||||
Version: pck.Version,
|
||||
},
|
||||
SemverCompatible: true,
|
||||
Creator: ctx.Doer,
|
||||
Metadata: pck.Metadata,
|
||||
},
|
||||
&packages_service.PackageFileCreationInfo{
|
||||
PackageFileInfo: packages_service.PackageFileInfo{
|
||||
Filename: strings.ToLower(pck.Version + ".tar.gz"),
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Data: buf,
|
||||
IsLead: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case packages_model.ErrDuplicatePackageVersion:
|
||||
apiError(ctx, http.StatusConflict, err)
|
||||
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
default:
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Resp.Header().Set("Location", fmt.Sprintf("%s/versions/new/finalize/%s/%s", baseURL(ctx), url.PathEscape(pck.Name), url.PathEscape(pck.Version)))
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// https://github.com/dart-lang/pub/blob/master/doc/repository-spec-v2.md#publishing-packages
|
||||
func FinalizePackage(ctx *context.Context) {
|
||||
packageName := ctx.PathParam("id")
|
||||
packageVersion := ctx.PathParam("version")
|
||||
|
||||
_, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypePub, packageName, packageVersion)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
type Success struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
type SuccessWrapper struct {
|
||||
Success Success `json:"success"`
|
||||
}
|
||||
|
||||
jsonResponse(ctx, http.StatusOK, SuccessWrapper{Success{}})
|
||||
}
|
||||
|
||||
// https://github.com/dart-lang/pub/blob/master/doc/repository-spec-v2.md#deprecated-download-a-specific-version-of-a-package
|
||||
func DownloadPackageFile(ctx *context.Context) {
|
||||
packageName := ctx.PathParam("id")
|
||||
packageVersion := strings.TrimSuffix(ctx.PathParam("version"), ".tar.gz")
|
||||
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypePub, packageName, packageVersion)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
pd, err := packages_model.GetPackageDescriptor(ctx, pv)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
pf := pd.Files[0].File
|
||||
|
||||
s, u, _, err := packages_service.OpenFileForDownload(ctx, pf, ctx.Req.Method)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
234
routers/api/packages/pypi/pypi.go
Normal file
234
routers/api/packages/pypi/pypi.go
Normal file
@@ -0,0 +1,234 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pypi
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
packages_module "code.gitea.io/gitea/modules/packages"
|
||||
pypi_module "code.gitea.io/gitea/modules/packages/pypi"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/validation"
|
||||
"code.gitea.io/gitea/routers/api/packages/helper"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
)
|
||||
|
||||
// https://peps.python.org/pep-0426/#name
|
||||
var (
|
||||
normalizer = strings.NewReplacer(".", "-", "_", "-")
|
||||
nameMatcher = regexp.MustCompile(`\A(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\.\-_]*[a-zA-Z0-9])\z`)
|
||||
)
|
||||
|
||||
// https://peps.python.org/pep-0440/#appendix-b-parsing-version-strings-with-regular-expressions
|
||||
var versionMatcher = regexp.MustCompile(`\Av?` +
|
||||
`(?:[0-9]+!)?` + // epoch
|
||||
`[0-9]+(?:\.[0-9]+)*` + // release segment
|
||||
`(?:[-_\.]?(?:a|b|c|rc|alpha|beta|pre|preview)[-_\.]?[0-9]*)?` + // pre-release
|
||||
`(?:-[0-9]+|[-_\.]?(?:post|rev|r)[-_\.]?[0-9]*)?` + // post release
|
||||
`(?:[-_\.]?dev[-_\.]?[0-9]*)?` + // dev release
|
||||
`(?:\+[a-z0-9]+(?:[-_\.][a-z0-9]+)*)?` + // local version
|
||||
`\z`)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
ctx.PlainText(status, message)
|
||||
}
|
||||
|
||||
// PackageMetadata returns the metadata for a single package
|
||||
func PackageMetadata(ctx *context.Context) {
|
||||
packageName := normalizer.Replace(ctx.PathParam("id"))
|
||||
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypePyPI, packageName)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pvs) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
|
||||
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
// sort package descriptors by version to mimic PyPI format
|
||||
sort.Slice(pds, func(i, j int) bool {
|
||||
return strings.Compare(pds[i].Version.Version, pds[j].Version.Version) < 0
|
||||
})
|
||||
|
||||
ctx.Data["RegistryURL"] = setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/pypi"
|
||||
ctx.Data["PackageDescriptor"] = pds[0]
|
||||
ctx.Data["PackageDescriptors"] = pds
|
||||
ctx.HTML(http.StatusOK, "api/packages/pypi/simple")
|
||||
}
|
||||
|
||||
// DownloadPackageFile serves the content of a package
|
||||
func DownloadPackageFile(ctx *context.Context) {
|
||||
packageName := normalizer.Replace(ctx.PathParam("id"))
|
||||
packageVersion := ctx.PathParam("version")
|
||||
filename := ctx.PathParam("filename")
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion(
|
||||
ctx,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypePyPI,
|
||||
Name: packageName,
|
||||
Version: packageVersion,
|
||||
},
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: filename,
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(err, packages_model.ErrPackageFileNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
|
||||
// UploadPackageFile adds a file to the package. If the package does not exist, it gets created.
|
||||
func UploadPackageFile(ctx *context.Context) {
|
||||
file, fileHeader, err := ctx.Req.FormFile("content")
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
buf, err := packages_module.CreateHashedBufferFromReader(file)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer buf.Close()
|
||||
|
||||
_, _, hashSHA256, _ := buf.Sums()
|
||||
|
||||
if !strings.EqualFold(ctx.Req.FormValue("sha256_digest"), hex.EncodeToString(hashSHA256)) {
|
||||
apiError(ctx, http.StatusBadRequest, "hash mismatch")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := buf.Seek(0, io.SeekStart); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
packageName := normalizer.Replace(ctx.Req.FormValue("name"))
|
||||
packageVersion := ctx.Req.FormValue("version")
|
||||
if !isValidNameAndVersion(packageName, packageVersion) {
|
||||
apiError(ctx, http.StatusBadRequest, "invalid name or version")
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure ctx.Req.Form exists.
|
||||
_ = ctx.Req.ParseForm()
|
||||
|
||||
var homepageURL string
|
||||
projectURLs := ctx.Req.Form["project_urls"]
|
||||
for _, purl := range projectURLs {
|
||||
label, url, found := strings.Cut(purl, ",")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
if normalizeLabel(label) != "homepage" {
|
||||
continue
|
||||
}
|
||||
homepageURL = strings.TrimSpace(url)
|
||||
break
|
||||
}
|
||||
|
||||
if len(homepageURL) == 0 {
|
||||
// TODO: Home-page is a deprecated metadata field. Remove this branch once it's no longer apart of the spec.
|
||||
homepageURL = ctx.Req.FormValue("home_page")
|
||||
}
|
||||
|
||||
if !validation.IsValidURL(homepageURL) {
|
||||
homepageURL = ""
|
||||
}
|
||||
|
||||
_, _, err = packages_service.CreatePackageOrAddFileToExisting(
|
||||
ctx,
|
||||
&packages_service.PackageCreationInfo{
|
||||
PackageInfo: packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypePyPI,
|
||||
Name: packageName,
|
||||
Version: packageVersion,
|
||||
},
|
||||
SemverCompatible: false,
|
||||
Creator: ctx.Doer,
|
||||
Metadata: &pypi_module.Metadata{
|
||||
Author: ctx.Req.FormValue("author"),
|
||||
Description: ctx.Req.FormValue("description"),
|
||||
LongDescription: ctx.Req.FormValue("long_description"),
|
||||
Summary: ctx.Req.FormValue("summary"),
|
||||
ProjectURL: homepageURL,
|
||||
License: ctx.Req.FormValue("license"),
|
||||
RequiresPython: ctx.Req.FormValue("requires_python"),
|
||||
},
|
||||
},
|
||||
&packages_service.PackageFileCreationInfo{
|
||||
PackageFileInfo: packages_service.PackageFileInfo{
|
||||
Filename: fileHeader.Filename,
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Data: buf,
|
||||
IsLead: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case packages_model.ErrDuplicatePackageFile:
|
||||
apiError(ctx, http.StatusConflict, err)
|
||||
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
default:
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusCreated)
|
||||
}
|
||||
|
||||
// Normalizes a Project-URL label.
|
||||
// See https://packaging.python.org/en/latest/specifications/well-known-project-urls/#label-normalization.
|
||||
func normalizeLabel(label string) string {
|
||||
var builder strings.Builder
|
||||
|
||||
// "A label is normalized by deleting all ASCII punctuation and whitespace, and then converting the result
|
||||
// to lowercase."
|
||||
for _, r := range label {
|
||||
if unicode.IsPunct(r) || unicode.IsSpace(r) {
|
||||
continue
|
||||
}
|
||||
builder.WriteRune(unicode.ToLower(r))
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func isValidNameAndVersion(packageName, packageVersion string) bool {
|
||||
return nameMatcher.MatchString(packageName) && versionMatcher.MatchString(packageVersion)
|
||||
}
|
||||
48
routers/api/packages/pypi/pypi_test.go
Normal file
48
routers/api/packages/pypi/pypi_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pypi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIsValidNameAndVersion(t *testing.T) {
|
||||
// The test cases below were created from the following Python PEPs:
|
||||
// https://peps.python.org/pep-0426/#name
|
||||
// https://peps.python.org/pep-0440/#appendix-b-parsing-version-strings-with-regular-expressions
|
||||
|
||||
// Valid Cases
|
||||
assert.True(t, isValidNameAndVersion("A", "1.0.1"))
|
||||
assert.True(t, isValidNameAndVersion("Test.Name.1234", "1.0.1"))
|
||||
assert.True(t, isValidNameAndVersion("test_name", "1.0.1"))
|
||||
assert.True(t, isValidNameAndVersion("test-name", "1.0.1"))
|
||||
assert.True(t, isValidNameAndVersion("test-name", "v1.0.1"))
|
||||
assert.True(t, isValidNameAndVersion("test-name", "2012.4"))
|
||||
assert.True(t, isValidNameAndVersion("test-name", "1.0.1-alpha"))
|
||||
assert.True(t, isValidNameAndVersion("test-name", "1.0.1a1"))
|
||||
assert.True(t, isValidNameAndVersion("test-name", "1.0b2.r345.dev456"))
|
||||
assert.True(t, isValidNameAndVersion("test-name", "1!1.0.1"))
|
||||
assert.True(t, isValidNameAndVersion("test-name", "1.0.1+local.1"))
|
||||
|
||||
// Invalid Cases
|
||||
assert.False(t, isValidNameAndVersion(".test-name", "1.0.1"))
|
||||
assert.False(t, isValidNameAndVersion("test!name", "1.0.1"))
|
||||
assert.False(t, isValidNameAndVersion("-test-name", "1.0.1"))
|
||||
assert.False(t, isValidNameAndVersion("test-name-", "1.0.1"))
|
||||
assert.False(t, isValidNameAndVersion("test-name", "a1.0.1"))
|
||||
assert.False(t, isValidNameAndVersion("test-name", "1.0.1aa"))
|
||||
assert.False(t, isValidNameAndVersion("test-name", "1.0.0-alpha.beta"))
|
||||
}
|
||||
|
||||
func TestNormalizeLabel(t *testing.T) {
|
||||
// Cases fetched from https://packaging.python.org/en/latest/specifications/well-known-project-urls/#label-normalization.
|
||||
assert.Equal(t, "homepage", normalizeLabel("Homepage"))
|
||||
assert.Equal(t, "homepage", normalizeLabel("Home-page"))
|
||||
assert.Equal(t, "homepage", normalizeLabel("Home page"))
|
||||
assert.Equal(t, "changelog", normalizeLabel("Change_Log"))
|
||||
assert.Equal(t, "whatsnew", normalizeLabel("What's New?"))
|
||||
assert.Equal(t, "github", normalizeLabel("github"))
|
||||
}
|
||||
318
routers/api/packages/rpm/rpm.go
Normal file
318
routers/api/packages/rpm/rpm.go
Normal file
@@ -0,0 +1,318 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package rpm
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
packages_module "code.gitea.io/gitea/modules/packages"
|
||||
rpm_module "code.gitea.io/gitea/modules/packages/rpm"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/routers/api/packages/helper"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
notify_service "code.gitea.io/gitea/services/notify"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
rpm_service "code.gitea.io/gitea/services/packages/rpm"
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
ctx.PlainText(status, message)
|
||||
}
|
||||
|
||||
// https://dnf.readthedocs.io/en/latest/conf_ref.html
|
||||
func GetRepositoryConfig(ctx *context.Context) {
|
||||
group := ctx.PathParam("group")
|
||||
|
||||
var groupParts []string
|
||||
if group != "" {
|
||||
groupParts = strings.Split(group, "/")
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%sapi/packages/%s/rpm", setting.AppURL, ctx.Package.Owner.Name)
|
||||
|
||||
ctx.PlainText(http.StatusOK, `[gitea-`+strings.Join(append([]string{ctx.Package.Owner.LowerName}, groupParts...), "-")+`]
|
||||
name=`+strings.Join(append([]string{ctx.Package.Owner.Name, setting.AppName}, groupParts...), " - ")+`
|
||||
baseurl=`+strings.Join(append([]string{url}, groupParts...), "/")+`
|
||||
enabled=1
|
||||
gpgcheck=1
|
||||
gpgkey=`+url+`/repository.key`)
|
||||
}
|
||||
|
||||
// Gets or creates the PGP public key used to sign repository metadata files
|
||||
func GetRepositoryKey(ctx *context.Context) {
|
||||
_, pub, err := rpm_service.GetOrCreateKeyPair(ctx, ctx.Package.Owner.ID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.ServeContent(strings.NewReader(pub), &context.ServeHeaderOptions{
|
||||
ContentType: "application/pgp-keys",
|
||||
Filename: "repository.key",
|
||||
})
|
||||
}
|
||||
|
||||
func CheckRepositoryFileExistence(ctx *context.Context) {
|
||||
pv, err := rpm_service.GetOrCreateRepositoryVersion(ctx, ctx.Package.Owner.ID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
pf, err := packages_model.GetFileForVersionByName(ctx, pv.ID, ctx.PathParam("filename"), ctx.PathParam("group"))
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.Status(http.StatusNotFound)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetServeHeaders(&context.ServeHeaderOptions{
|
||||
Filename: pf.Name,
|
||||
LastModified: pf.CreatedUnix.AsLocalTime(),
|
||||
})
|
||||
ctx.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// Gets a pre-generated repository metadata file
|
||||
func GetRepositoryFile(ctx *context.Context) {
|
||||
pv, err := rpm_service.GetOrCreateRepositoryVersion(ctx, ctx.Package.Owner.ID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion(
|
||||
ctx,
|
||||
pv,
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: ctx.PathParam("filename"),
|
||||
CompositeKey: ctx.PathParam("group"),
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
|
||||
func UploadPackageFile(ctx *context.Context) {
|
||||
upload, needToClose, err := ctx.UploadStream()
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if needToClose {
|
||||
defer upload.Close()
|
||||
}
|
||||
|
||||
buf, err := packages_module.CreateHashedBufferFromReader(upload)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer buf.Close()
|
||||
|
||||
if setting.Packages.DefaultRPMSignEnabled || ctx.FormBool("sign") {
|
||||
priv, _, err := rpm_service.GetOrCreateKeyPair(ctx, ctx.Package.Owner.ID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
signedBuf, err := rpm_service.SignPackage(buf, priv)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
defer signedBuf.Close()
|
||||
|
||||
buf = signedBuf
|
||||
}
|
||||
|
||||
pck, err := rpm_module.ParsePackage(buf)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := buf.Seek(0, io.SeekStart); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
fileMetadataRaw, err := json.Marshal(pck.FileMetadata)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
group := ctx.PathParam("group")
|
||||
_, _, err = packages_service.CreatePackageOrAddFileToExisting(
|
||||
ctx,
|
||||
&packages_service.PackageCreationInfo{
|
||||
PackageInfo: packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeRpm,
|
||||
Name: pck.Name,
|
||||
Version: pck.Version,
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Metadata: pck.VersionMetadata,
|
||||
},
|
||||
&packages_service.PackageFileCreationInfo{
|
||||
PackageFileInfo: packages_service.PackageFileInfo{
|
||||
Filename: fmt.Sprintf("%s-%s.%s.rpm", pck.Name, pck.Version, pck.FileMetadata.Architecture),
|
||||
CompositeKey: group,
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Data: buf,
|
||||
IsLead: true,
|
||||
Properties: map[string]string{
|
||||
rpm_module.PropertyGroup: group,
|
||||
rpm_module.PropertyArchitecture: pck.FileMetadata.Architecture,
|
||||
rpm_module.PropertyMetadata: string(fileMetadataRaw),
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case packages_model.ErrDuplicatePackageVersion, packages_model.ErrDuplicatePackageFile:
|
||||
apiError(ctx, http.StatusConflict, err)
|
||||
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
default:
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := rpm_service.BuildSpecificRepositoryFiles(ctx, ctx.Package.Owner.ID, group); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusCreated)
|
||||
}
|
||||
|
||||
func DownloadPackageFile(ctx *context.Context) {
|
||||
name := ctx.PathParam("name")
|
||||
version := ctx.PathParam("version")
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion(
|
||||
ctx,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeRpm,
|
||||
Name: name,
|
||||
Version: version,
|
||||
},
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: fmt.Sprintf("%s-%s.%s.rpm", name, version, ctx.PathParam("architecture")),
|
||||
CompositeKey: ctx.PathParam("group"),
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
|
||||
func DeletePackageFile(webctx *context.Context) {
|
||||
group := webctx.PathParam("group")
|
||||
name := webctx.PathParam("name")
|
||||
version := webctx.PathParam("version")
|
||||
architecture := webctx.PathParam("architecture")
|
||||
|
||||
var pd *packages_model.PackageDescriptor
|
||||
|
||||
err := db.WithTx(webctx, func(ctx stdctx.Context) error {
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx,
|
||||
webctx.Package.Owner.ID,
|
||||
packages_model.TypeRpm,
|
||||
name,
|
||||
version,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pf, err := packages_model.GetFileForVersionByName(
|
||||
ctx,
|
||||
pv.ID,
|
||||
fmt.Sprintf("%s-%s.%s.rpm", name, version, architecture),
|
||||
group,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := packages_service.DeletePackageFile(ctx, pf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
has, err := packages_model.HasVersionFileReferences(ctx, pv.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !has {
|
||||
pd, err = packages_model.GetPackageDescriptor(ctx, pv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := packages_service.DeletePackageVersionAndReferences(ctx, pv); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
apiError(webctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(webctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if pd != nil {
|
||||
notify_service.PackageDelete(webctx, webctx.Doer, pd)
|
||||
}
|
||||
|
||||
if err := rpm_service.BuildSpecificRepositoryFiles(webctx, webctx.Package.Owner.ID, group); err != nil {
|
||||
apiError(webctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
webctx.Status(http.StatusNoContent)
|
||||
}
|
||||
465
routers/api/packages/rubygems/rubygems.go
Normal file
465
routers/api/packages/rubygems/rubygems.go
Normal file
@@ -0,0 +1,465 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package rubygems
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"compress/zlib"
|
||||
"crypto/md5"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
"code.gitea.io/gitea/modules/cache"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
packages_module "code.gitea.io/gitea/modules/packages"
|
||||
rubygems_module "code.gitea.io/gitea/modules/packages/rubygems"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/routers/api/packages/helper"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
ctx.PlainText(status, message)
|
||||
}
|
||||
|
||||
// EnumeratePackages serves the package list
|
||||
func EnumeratePackages(ctx *context.Context) {
|
||||
packages, err := packages_model.GetVersionsByPackageType(ctx, ctx.Package.Owner.ID, packages_model.TypeRubyGems)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
enumeratePackages(ctx, "specs.4.8", packages)
|
||||
}
|
||||
|
||||
// EnumeratePackagesLatest serves the list of the latest version of every package
|
||||
func EnumeratePackagesLatest(ctx *context.Context) {
|
||||
pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Type: packages_model.TypeRubyGems,
|
||||
IsInternal: optional.Some(false),
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
enumeratePackages(ctx, "latest_specs.4.8", pvs)
|
||||
}
|
||||
|
||||
// EnumeratePackagesPreRelease is not supported and serves an empty list
|
||||
func EnumeratePackagesPreRelease(ctx *context.Context) {
|
||||
enumeratePackages(ctx, "prerelease_specs.4.8", []*packages_model.PackageVersion{})
|
||||
}
|
||||
|
||||
func enumeratePackages(ctx *context.Context, filename string, pvs []*packages_model.PackageVersion) {
|
||||
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
specs := make([]any, 0, len(pds))
|
||||
for _, p := range pds {
|
||||
specs = append(specs, []any{
|
||||
p.Package.Name,
|
||||
&rubygems_module.RubyUserMarshal{
|
||||
Name: "Gem::Version",
|
||||
Value: []string{p.Version.Version},
|
||||
},
|
||||
p.Metadata.(*rubygems_module.Metadata).Platform,
|
||||
})
|
||||
}
|
||||
|
||||
ctx.SetServeHeaders(&context.ServeHeaderOptions{
|
||||
Filename: filename + ".gz",
|
||||
})
|
||||
|
||||
zw := gzip.NewWriter(ctx.Resp)
|
||||
defer zw.Close()
|
||||
|
||||
zw.Name = filename
|
||||
|
||||
if err := rubygems_module.NewMarshalEncoder(zw).Encode(specs); err != nil {
|
||||
ctx.ServerError("Download file failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ServePackageSpecification serves the compressed Gemspec file of a package
|
||||
func ServePackageSpecification(ctx *context.Context) {
|
||||
filename := ctx.PathParam("filename")
|
||||
|
||||
if !strings.HasSuffix(filename, ".gemspec.rz") {
|
||||
apiError(ctx, http.StatusNotImplemented, nil)
|
||||
return
|
||||
}
|
||||
|
||||
pvs, err := getVersionsByFilename(ctx, filename[:len(filename)-10]+"gem")
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(pvs) != 1 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
pd, err := packages_model.GetPackageDescriptor(ctx, pvs[0])
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetServeHeaders(&context.ServeHeaderOptions{
|
||||
Filename: filename,
|
||||
})
|
||||
|
||||
zw := zlib.NewWriter(ctx.Resp)
|
||||
defer zw.Close()
|
||||
|
||||
metadata := pd.Metadata.(*rubygems_module.Metadata)
|
||||
|
||||
// create a Ruby Gem::Specification object
|
||||
spec := &rubygems_module.RubyUserDef{
|
||||
Name: "Gem::Specification",
|
||||
Value: []any{
|
||||
"3.2.3", // @rubygems_version
|
||||
4, // @specification_version,
|
||||
pd.Package.Name,
|
||||
&rubygems_module.RubyUserMarshal{
|
||||
Name: "Gem::Version",
|
||||
Value: []string{pd.Version.Version},
|
||||
},
|
||||
nil, // date
|
||||
metadata.Summary, // @summary
|
||||
nil, // @required_ruby_version
|
||||
nil, // @required_rubygems_version
|
||||
metadata.Platform, // @original_platform
|
||||
[]any{}, // @dependencies
|
||||
nil, // rubyforge_project
|
||||
"", // @email
|
||||
metadata.Authors,
|
||||
metadata.Description,
|
||||
metadata.ProjectURL,
|
||||
true, // has_rdoc
|
||||
metadata.Platform, // @new_platform
|
||||
nil,
|
||||
metadata.Licenses,
|
||||
},
|
||||
}
|
||||
|
||||
if err := rubygems_module.NewMarshalEncoder(zw).Encode(spec); err != nil {
|
||||
ctx.ServerError("Download file failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
// DownloadPackageFile serves the content of a package
|
||||
func DownloadPackageFile(ctx *context.Context) {
|
||||
filename := ctx.PathParam("filename")
|
||||
|
||||
pvs, err := getVersionsByFilename(ctx, filename)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(pvs) != 1 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion(
|
||||
ctx,
|
||||
pvs[0],
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: filename,
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageFileNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
|
||||
// UploadPackageFile adds a file to the package. If the package does not exist, it gets created.
|
||||
func UploadPackageFile(ctx *context.Context) {
|
||||
upload, needToClose, err := ctx.UploadStream()
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if needToClose {
|
||||
defer upload.Close()
|
||||
}
|
||||
|
||||
buf, err := packages_module.CreateHashedBufferFromReader(upload)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer buf.Close()
|
||||
|
||||
rp, err := rubygems_module.ParsePackageMetaData(buf)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := buf.Seek(0, io.SeekStart); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
filename := makeGemFullFileName(rp.Name, rp.Version, rp.Metadata.Platform)
|
||||
|
||||
_, _, err = packages_service.CreatePackageAndAddFile(
|
||||
ctx,
|
||||
&packages_service.PackageCreationInfo{
|
||||
PackageInfo: packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeRubyGems,
|
||||
Name: rp.Name,
|
||||
Version: rp.Version,
|
||||
},
|
||||
SemverCompatible: true,
|
||||
Creator: ctx.Doer,
|
||||
Metadata: rp.Metadata,
|
||||
},
|
||||
&packages_service.PackageFileCreationInfo{
|
||||
PackageFileInfo: packages_service.PackageFileInfo{
|
||||
Filename: filename,
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Data: buf,
|
||||
IsLead: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case packages_model.ErrDuplicatePackageVersion:
|
||||
apiError(ctx, http.StatusConflict, err)
|
||||
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
default:
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusCreated)
|
||||
}
|
||||
|
||||
// DeletePackage deletes a package
|
||||
func DeletePackage(ctx *context.Context) {
|
||||
// Go populates the form only for POST, PUT and PATCH requests
|
||||
if err := ctx.Req.ParseMultipartForm(32 << 20); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
packageName := ctx.FormString("gem_name")
|
||||
packageVersion := ctx.FormString("version")
|
||||
|
||||
err := packages_service.RemovePackageVersionByNameAndVersion(
|
||||
ctx,
|
||||
ctx.Doer,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeRubyGems,
|
||||
Name: packageName,
|
||||
Version: packageVersion,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
}
|
||||
|
||||
// GetPackageInfo returns a custom text based format for the single rubygem with a line for each version of the rubygem
|
||||
// ref: https://guides.rubygems.org/rubygems-org-compact-index-api/
|
||||
func GetPackageInfo(ctx *context.Context) {
|
||||
packageName := ctx.PathParam("packagename")
|
||||
versions, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeRubyGems, packageName)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(versions) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
infoContent, err := makePackageInfo(ctx, versions, cache.NewEphemeralCache())
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
ctx.PlainText(http.StatusOK, infoContent)
|
||||
}
|
||||
|
||||
// GetAllPackagesVersions returns a custom text-based format containing information about all versions of all rubygems.
|
||||
// ref: https://guides.rubygems.org/rubygems-org-compact-index-api/
|
||||
func GetAllPackagesVersions(ctx *context.Context) {
|
||||
packages, err := packages_model.GetPackagesByType(ctx, ctx.Package.Owner.ID, packages_model.TypeRubyGems)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ephemeralCache := cache.NewEphemeralCache()
|
||||
out := &strings.Builder{}
|
||||
out.WriteString("---\n")
|
||||
for _, pkg := range packages {
|
||||
versions, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeRubyGems, pkg.Name)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(versions) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := makePackageInfo(ctx, versions, ephemeralCache)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
// format: RUBYGEM [-]VERSION_PLATFORM[,VERSION_PLATFORM],...] MD5
|
||||
_, _ = fmt.Fprintf(out, "%s ", pkg.Name)
|
||||
for i, v := range versions {
|
||||
sep := util.Iif(i == len(versions)-1, "", ",")
|
||||
pd, err := packages_model.GetPackageDescriptorWithCache(ctx, v, ephemeralCache)
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
continue
|
||||
} else if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writePackageVersionForList(pd.Metadata, v.Version, sep, out)
|
||||
}
|
||||
_, _ = fmt.Fprintf(out, " %x\n", md5.Sum([]byte(info)))
|
||||
}
|
||||
|
||||
ctx.PlainText(http.StatusOK, out.String())
|
||||
}
|
||||
|
||||
func writePackageVersionForList(metadata any, version, sep string, out *strings.Builder) {
|
||||
if metadata, _ := metadata.(*rubygems_module.Metadata); metadata != nil && metadata.Platform != "" && metadata.Platform != "ruby" {
|
||||
// VERSION_PLATFORM (see comment above in GetAllPackagesVersions)
|
||||
_, _ = fmt.Fprintf(out, "%s_%s%s", version, metadata.Platform, sep)
|
||||
} else {
|
||||
// VERSION only
|
||||
_, _ = fmt.Fprintf(out, "%s%s", version, sep)
|
||||
}
|
||||
}
|
||||
|
||||
func writePackageVersionRequirements(prefix string, reqs []rubygems_module.VersionRequirement, out *strings.Builder) {
|
||||
out.WriteString(prefix)
|
||||
if len(reqs) == 0 {
|
||||
reqs = []rubygems_module.VersionRequirement{{Restriction: ">=", Version: "0"}}
|
||||
}
|
||||
for i, req := range reqs {
|
||||
sep := util.Iif(i == 0, "", "&")
|
||||
_, _ = fmt.Fprintf(out, "%s%s %s", sep, req.Restriction, req.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func writePackageVersionForDependency(version, platform string, out *strings.Builder) {
|
||||
if platform != "" && platform != "ruby" {
|
||||
// VERSION-PLATFORM (see comment below in makePackageVersionDependency)
|
||||
_, _ = fmt.Fprintf(out, "%s-%s ", version, platform)
|
||||
} else {
|
||||
// VERSION only
|
||||
_, _ = fmt.Fprintf(out, "%s ", version)
|
||||
}
|
||||
}
|
||||
|
||||
func makePackageVersionDependency(ctx *context.Context, version *packages_model.PackageVersion, c *cache.EphemeralCache) (string, error) {
|
||||
// format: VERSION[-PLATFORM] [DEPENDENCY[,DEPENDENCY,...]]|REQUIREMENT[,REQUIREMENT,...]
|
||||
// DEPENDENCY: GEM:CONSTRAINT[&CONSTRAINT]
|
||||
// REQUIREMENT: KEY:VALUE (always contains "checksum")
|
||||
pd, err := packages_model.GetPackageDescriptorWithCache(ctx, version, c)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
metadata := pd.Metadata.(*rubygems_module.Metadata)
|
||||
fullFilename := makeGemFullFileName(pd.Package.Name, version.Version, metadata.Platform)
|
||||
file, err := packages_model.GetFileForVersionByName(ctx, version.ID, fullFilename, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
blob, err := packages_model.GetBlobByID(ctx, file.BlobID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
buf := &strings.Builder{}
|
||||
writePackageVersionForDependency(version.Version, metadata.Platform, buf)
|
||||
for i, dep := range metadata.RuntimeDependencies {
|
||||
sep := util.Iif(i == 0, "", ",")
|
||||
writePackageVersionRequirements(fmt.Sprintf("%s%s:", sep, dep.Name), dep.Version, buf)
|
||||
}
|
||||
_, _ = fmt.Fprintf(buf, "|checksum:%s", blob.HashSHA256)
|
||||
if len(metadata.RequiredRubyVersion) != 0 {
|
||||
writePackageVersionRequirements(",ruby:", metadata.RequiredRubyVersion, buf)
|
||||
}
|
||||
if len(metadata.RequiredRubygemsVersion) != 0 {
|
||||
writePackageVersionRequirements(",rubygems:", metadata.RequiredRubygemsVersion, buf)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func makePackageInfo(ctx *context.Context, versions []*packages_model.PackageVersion, c *cache.EphemeralCache) (string, error) {
|
||||
ret := "---\n"
|
||||
for _, v := range versions {
|
||||
dep, err := makePackageVersionDependency(ctx, v, c)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ret += dep + "\n"
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func makeGemFullFileName(gemName, version, platform string) string {
|
||||
var basename string
|
||||
if platform == "" || platform == "ruby" {
|
||||
basename = fmt.Sprintf("%s-%s", gemName, version)
|
||||
} else {
|
||||
basename = fmt.Sprintf("%s-%s-%s", gemName, version, platform)
|
||||
}
|
||||
return strings.ToLower(basename) + ".gem"
|
||||
}
|
||||
|
||||
func getVersionsByFilename(ctx *context.Context, filename string) ([]*packages_model.PackageVersion, error) {
|
||||
pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Type: packages_model.TypeRubyGems,
|
||||
HasFileWithName: filename,
|
||||
IsInternal: optional.Some(false),
|
||||
})
|
||||
return pvs, err
|
||||
}
|
||||
41
routers/api/packages/rubygems/rubygems_test.go
Normal file
41
routers/api/packages/rubygems/rubygems_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package rubygems
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
rubygems_module "code.gitea.io/gitea/modules/packages/rubygems"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestWritePackageVersion(t *testing.T) {
|
||||
buf := &strings.Builder{}
|
||||
|
||||
writePackageVersionForList(nil, "1.0", " ", buf)
|
||||
assert.Equal(t, "1.0 ", buf.String())
|
||||
buf.Reset()
|
||||
|
||||
writePackageVersionForList(&rubygems_module.Metadata{Platform: "ruby"}, "1.0", " ", buf)
|
||||
assert.Equal(t, "1.0 ", buf.String())
|
||||
buf.Reset()
|
||||
|
||||
writePackageVersionForList(&rubygems_module.Metadata{Platform: "linux"}, "1.0", " ", buf)
|
||||
assert.Equal(t, "1.0_linux ", buf.String())
|
||||
buf.Reset()
|
||||
|
||||
writePackageVersionForDependency("1.0", "", buf)
|
||||
assert.Equal(t, "1.0 ", buf.String())
|
||||
buf.Reset()
|
||||
|
||||
writePackageVersionForDependency("1.0", "ruby", buf)
|
||||
assert.Equal(t, "1.0 ", buf.String())
|
||||
buf.Reset()
|
||||
|
||||
writePackageVersionForDependency("1.0", "os", buf)
|
||||
assert.Equal(t, "1.0-os ", buf.String())
|
||||
buf.Reset()
|
||||
}
|
||||
493
routers/api/packages/swift/swift.go
Normal file
493
routers/api/packages/swift/swift.go
Normal file
@@ -0,0 +1,493 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package swift
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
packages_module "code.gitea.io/gitea/modules/packages"
|
||||
swift_module "code.gitea.io/gitea/modules/packages/swift"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/routers/api/packages/helper"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
|
||||
"github.com/hashicorp/go-version"
|
||||
)
|
||||
|
||||
// https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/Registry.md#35-api-versioning
|
||||
const (
|
||||
AcceptJSON = "application/vnd.swift.registry.v1+json"
|
||||
AcceptSwift = "application/vnd.swift.registry.v1+swift"
|
||||
AcceptZip = "application/vnd.swift.registry.v1+zip"
|
||||
)
|
||||
|
||||
var (
|
||||
// https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/Registry.md#361-package-scope
|
||||
scopePattern = regexp.MustCompile(`\A[a-zA-Z0-9][a-zA-Z0-9-]{0,38}\z`)
|
||||
// https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/Registry.md#362-package-name
|
||||
namePattern = regexp.MustCompile(`\A[a-zA-Z0-9][a-zA-Z0-9-_]{0,99}\z`)
|
||||
)
|
||||
|
||||
type headers struct {
|
||||
Status int
|
||||
ContentType string
|
||||
Digest string
|
||||
Location string
|
||||
Link string
|
||||
}
|
||||
|
||||
// https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/Registry.md#35-api-versioning
|
||||
func setResponseHeaders(resp http.ResponseWriter, h *headers) {
|
||||
if h.ContentType != "" {
|
||||
resp.Header().Set("Content-Type", h.ContentType)
|
||||
}
|
||||
if h.Digest != "" {
|
||||
resp.Header().Set("Digest", "sha256="+h.Digest)
|
||||
}
|
||||
if h.Location != "" {
|
||||
resp.Header().Set("Location", h.Location)
|
||||
}
|
||||
if h.Link != "" {
|
||||
resp.Header().Set("Link", h.Link)
|
||||
}
|
||||
resp.Header().Set("Content-Version", "1")
|
||||
if h.Status != 0 {
|
||||
resp.WriteHeader(h.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/Registry.md#33-error-handling
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
// https://www.rfc-editor.org/rfc/rfc7807
|
||||
type Problem struct {
|
||||
Status int `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
setResponseHeaders(ctx.Resp, &headers{
|
||||
Status: status,
|
||||
ContentType: "application/problem+json",
|
||||
})
|
||||
_ = json.NewEncoder(ctx.Resp).Encode(Problem{
|
||||
Status: status,
|
||||
Detail: message,
|
||||
})
|
||||
}
|
||||
|
||||
// https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/Registry.md#35-api-versioning
|
||||
func CheckAcceptMediaType(requiredAcceptHeader string) func(ctx *context.Context) {
|
||||
return func(ctx *context.Context) {
|
||||
accept := ctx.Req.Header.Get("Accept")
|
||||
if accept != "" && accept != requiredAcceptHeader {
|
||||
apiError(ctx, http.StatusBadRequest, fmt.Sprintf("Unexpected accept header. Should be '%s'.", requiredAcceptHeader))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/PackageRegistryUsage.md#registry-authentication
|
||||
func CheckAuthenticate(ctx *context.Context) {
|
||||
if ctx.Doer == nil {
|
||||
apiError(ctx, http.StatusUnauthorized, nil)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func buildPackageID(scope, name string) string {
|
||||
return scope + "." + name
|
||||
}
|
||||
|
||||
type Release struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type EnumeratePackageVersionsResponse struct {
|
||||
Releases map[string]Release `json:"releases"`
|
||||
}
|
||||
|
||||
// https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/Registry.md#41-list-package-releases
|
||||
func EnumeratePackageVersions(ctx *context.Context) {
|
||||
packageScope := ctx.PathParam("scope")
|
||||
packageName := ctx.PathParam("name")
|
||||
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeSwift, buildPackageID(packageScope, packageName))
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pvs) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
sort.Slice(pds, func(i, j int) bool {
|
||||
return pds[i].SemVer.LessThan(pds[j].SemVer)
|
||||
})
|
||||
|
||||
baseURL := fmt.Sprintf("%sapi/packages/%s/swift/%s/%s/", setting.AppURL, ctx.Package.Owner.LowerName, packageScope, packageName)
|
||||
|
||||
releases := make(map[string]Release)
|
||||
for _, pd := range pds {
|
||||
version := pd.SemVer.String()
|
||||
releases[version] = Release{
|
||||
URL: baseURL + version,
|
||||
}
|
||||
}
|
||||
|
||||
setResponseHeaders(ctx.Resp, &headers{
|
||||
Link: fmt.Sprintf(`<%s%s>; rel="latest-version"`, baseURL, pds[len(pds)-1].Version.Version),
|
||||
})
|
||||
|
||||
ctx.JSON(http.StatusOK, EnumeratePackageVersionsResponse{
|
||||
Releases: releases,
|
||||
})
|
||||
}
|
||||
|
||||
type Resource struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Checksum string `json:"checksum"`
|
||||
}
|
||||
|
||||
type PackageVersionMetadataResponse struct {
|
||||
ID string `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Resources []Resource `json:"resources"`
|
||||
Metadata *swift_module.SoftwareSourceCode `json:"metadata"`
|
||||
}
|
||||
|
||||
// https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/Registry.md#endpoint-2
|
||||
func PackageVersionMetadata(ctx *context.Context) {
|
||||
id := buildPackageID(ctx.PathParam("scope"), ctx.PathParam("name"))
|
||||
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeSwift, id, ctx.PathParam("version"))
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
pd, err := packages_model.GetPackageDescriptor(ctx, pv)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
metadata := pd.Metadata.(*swift_module.Metadata)
|
||||
|
||||
setResponseHeaders(ctx.Resp, &headers{})
|
||||
|
||||
ctx.JSON(http.StatusOK, PackageVersionMetadataResponse{
|
||||
ID: id,
|
||||
Version: pd.Version.Version,
|
||||
Resources: []Resource{
|
||||
{
|
||||
Name: "source-archive",
|
||||
Type: "application/zip",
|
||||
Checksum: pd.Files[0].Blob.HashSHA256,
|
||||
},
|
||||
},
|
||||
Metadata: &swift_module.SoftwareSourceCode{
|
||||
Context: []string{"http://schema.org/"},
|
||||
Type: "SoftwareSourceCode",
|
||||
Name: pd.PackageProperties.GetByName(swift_module.PropertyName),
|
||||
Version: pd.Version.Version,
|
||||
Description: metadata.Description,
|
||||
Keywords: metadata.Keywords,
|
||||
CodeRepository: metadata.RepositoryURL,
|
||||
License: metadata.License,
|
||||
ProgrammingLanguage: swift_module.ProgrammingLanguage{
|
||||
Type: "ComputerLanguage",
|
||||
Name: "Swift",
|
||||
URL: "https://swift.org",
|
||||
},
|
||||
Author: swift_module.Person{
|
||||
Type: "Person",
|
||||
Name: metadata.Author.String(),
|
||||
GivenName: metadata.Author.GivenName,
|
||||
MiddleName: metadata.Author.MiddleName,
|
||||
FamilyName: metadata.Author.FamilyName,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/Registry.md#43-fetch-manifest-for-a-package-release
|
||||
func DownloadManifest(ctx *context.Context) {
|
||||
packageScope := ctx.PathParam("scope")
|
||||
packageName := ctx.PathParam("name")
|
||||
packageVersion := ctx.PathParam("version")
|
||||
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeSwift, buildPackageID(packageScope, packageName), packageVersion)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
pd, err := packages_model.GetPackageDescriptor(ctx, pv)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
swiftVersion := ctx.FormTrim("swift-version")
|
||||
if swiftVersion != "" {
|
||||
v, err := version.NewVersion(swiftVersion)
|
||||
if err == nil {
|
||||
swiftVersion = swift_module.TrimmedVersionString(v)
|
||||
}
|
||||
}
|
||||
m, ok := pd.Metadata.(*swift_module.Metadata).Manifests[swiftVersion]
|
||||
if !ok {
|
||||
setResponseHeaders(ctx.Resp, &headers{
|
||||
Status: http.StatusSeeOther,
|
||||
Location: fmt.Sprintf("%sapi/packages/%s/swift/%s/%s/%s/Package.swift", setting.AppURL, ctx.Package.Owner.LowerName, packageScope, packageName, packageVersion),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setResponseHeaders(ctx.Resp, &headers{})
|
||||
|
||||
filename := "Package.swift"
|
||||
if swiftVersion != "" {
|
||||
filename = fmt.Sprintf("Package@swift-%s.swift", swiftVersion)
|
||||
}
|
||||
|
||||
ctx.ServeContent(strings.NewReader(m.Content), &context.ServeHeaderOptions{
|
||||
ContentType: "text/x-swift",
|
||||
Filename: filename,
|
||||
LastModified: pv.CreatedUnix.AsLocalTime(),
|
||||
})
|
||||
}
|
||||
|
||||
// formFileOptionalReadCloser returns (nil, nil) if the formKey is not present.
|
||||
func formFileOptionalReadCloser(ctx *context.Context, formKey string) (io.ReadCloser, error) {
|
||||
multipartFile, _, err := ctx.Req.FormFile(formKey)
|
||||
if err != nil && !errors.Is(err, http.ErrMissingFile) {
|
||||
return nil, err
|
||||
}
|
||||
if multipartFile != nil {
|
||||
return multipartFile, nil
|
||||
}
|
||||
|
||||
content := ctx.Req.FormValue(formKey)
|
||||
if content == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return io.NopCloser(strings.NewReader(content)), nil
|
||||
}
|
||||
|
||||
// UploadPackageFile refers to https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/Registry.md#endpoint-6
|
||||
func UploadPackageFile(ctx *context.Context) {
|
||||
packageScope := ctx.PathParam("scope")
|
||||
packageName := ctx.PathParam("name")
|
||||
|
||||
v, err := version.NewVersion(ctx.PathParam("version"))
|
||||
|
||||
if !scopePattern.MatchString(packageScope) || !namePattern.MatchString(packageName) || err != nil {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
packageVersion := v.Core().String()
|
||||
|
||||
file, err := formFileOptionalReadCloser(ctx, "source-archive")
|
||||
if file == nil || err != nil {
|
||||
apiError(ctx, http.StatusBadRequest, "unable to read source-archive file")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
buf, err := packages_module.CreateHashedBufferFromReader(file)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer buf.Close()
|
||||
|
||||
mr, err := formFileOptionalReadCloser(ctx, "metadata")
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusBadRequest, "unable to read metadata file")
|
||||
return
|
||||
}
|
||||
if mr != nil {
|
||||
defer mr.Close()
|
||||
}
|
||||
|
||||
pck, err := swift_module.ParsePackage(buf, buf.Size(), mr)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := buf.Seek(0, io.SeekStart); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
pv, _, err := packages_service.CreatePackageAndAddFile(
|
||||
ctx,
|
||||
&packages_service.PackageCreationInfo{
|
||||
PackageInfo: packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeSwift,
|
||||
Name: buildPackageID(packageScope, packageName),
|
||||
Version: packageVersion,
|
||||
},
|
||||
SemverCompatible: true,
|
||||
Creator: ctx.Doer,
|
||||
Metadata: pck.Metadata,
|
||||
PackageProperties: map[string]string{
|
||||
swift_module.PropertyScope: packageScope,
|
||||
swift_module.PropertyName: packageName,
|
||||
},
|
||||
},
|
||||
&packages_service.PackageFileCreationInfo{
|
||||
PackageFileInfo: packages_service.PackageFileInfo{
|
||||
Filename: fmt.Sprintf("%s-%s.zip", packageName, packageVersion),
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Data: buf,
|
||||
IsLead: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case packages_model.ErrDuplicatePackageVersion:
|
||||
apiError(ctx, http.StatusConflict, err)
|
||||
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
default:
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for _, url := range pck.RepositoryURLs {
|
||||
_, err = packages_model.InsertProperty(ctx, packages_model.PropertyTypeVersion, pv.ID, swift_module.PropertyRepositoryURL, url)
|
||||
if err != nil {
|
||||
log.Error("InsertProperty failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
setResponseHeaders(ctx.Resp, &headers{})
|
||||
|
||||
ctx.Status(http.StatusCreated)
|
||||
}
|
||||
|
||||
// https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/Registry.md#endpoint-4
|
||||
func DownloadPackageFile(ctx *context.Context) {
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeSwift, buildPackageID(ctx.PathParam("scope"), ctx.PathParam("name")), ctx.PathParam("version"))
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
pd, err := packages_model.GetPackageDescriptor(ctx, pv)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
pf := pd.Files[0].File
|
||||
|
||||
s, u, _, err := packages_service.OpenFileForDownload(ctx, pf, ctx.Req.Method)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
setResponseHeaders(ctx.Resp, &headers{
|
||||
Digest: pd.Files[0].Blob.HashSHA256,
|
||||
})
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf, &context.ServeHeaderOptions{
|
||||
Filename: pf.Name,
|
||||
ContentType: "application/zip",
|
||||
LastModified: pf.CreatedUnix.AsLocalTime(),
|
||||
})
|
||||
}
|
||||
|
||||
type LookupPackageIdentifiersResponse struct {
|
||||
Identifiers []string `json:"identifiers"`
|
||||
}
|
||||
|
||||
// https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/Registry.md#endpoint-5
|
||||
func LookupPackageIdentifiers(ctx *context.Context) {
|
||||
url := ctx.FormTrim("url")
|
||||
if url == "" {
|
||||
apiError(ctx, http.StatusBadRequest, nil)
|
||||
return
|
||||
}
|
||||
|
||||
pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Type: packages_model.TypeSwift,
|
||||
Properties: map[string]string{
|
||||
swift_module.PropertyRepositoryURL: url,
|
||||
},
|
||||
IsInternal: optional.Some(false),
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(pvs) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
identifiers := make([]string, 0, len(pds))
|
||||
for _, pd := range pds {
|
||||
identifiers = append(identifiers, pd.Package.Name)
|
||||
}
|
||||
|
||||
setResponseHeaders(ctx.Resp, &headers{})
|
||||
|
||||
ctx.JSON(http.StatusOK, LookupPackageIdentifiersResponse{
|
||||
Identifiers: identifiers,
|
||||
})
|
||||
}
|
||||
243
routers/api/packages/vagrant/vagrant.go
Normal file
243
routers/api/packages/vagrant/vagrant.go
Normal file
@@ -0,0 +1,243 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vagrant
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
packages_module "code.gitea.io/gitea/modules/packages"
|
||||
vagrant_module "code.gitea.io/gitea/modules/packages/vagrant"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/routers/api/packages/helper"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
|
||||
"github.com/hashicorp/go-version"
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
ctx.JSON(status, struct {
|
||||
Errors []string `json:"errors"`
|
||||
}{
|
||||
Errors: []string{
|
||||
message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func CheckAuthenticate(ctx *context.Context) {
|
||||
if ctx.Doer == nil {
|
||||
apiError(ctx, http.StatusUnauthorized, "Invalid access token")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func CheckBoxAvailable(ctx *context.Context) {
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeVagrant, ctx.PathParam("name"))
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pvs) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, nil) // needs to be Content-Type: application/json
|
||||
}
|
||||
|
||||
type packageMetadata struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
ShortDescription string `json:"short_description,omitempty"`
|
||||
Versions []*versionMetadata `json:"versions"`
|
||||
}
|
||||
|
||||
type versionMetadata struct {
|
||||
Version string `json:"version"`
|
||||
Status string `json:"status"`
|
||||
DescriptionHTML string `json:"description_html,omitempty"`
|
||||
DescriptionMarkdown string `json:"description_markdown,omitempty"`
|
||||
Providers []*providerData `json:"providers"`
|
||||
}
|
||||
|
||||
type providerData struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Checksum string `json:"checksum"`
|
||||
ChecksumType string `json:"checksum_type"`
|
||||
}
|
||||
|
||||
func packageDescriptorToMetadata(baseURL string, pd *packages_model.PackageDescriptor) *versionMetadata {
|
||||
versionURL := baseURL + "/" + url.PathEscape(pd.Version.Version)
|
||||
|
||||
providers := make([]*providerData, 0, len(pd.Files))
|
||||
|
||||
for _, f := range pd.Files {
|
||||
providers = append(providers, &providerData{
|
||||
Name: f.Properties.GetByName(vagrant_module.PropertyProvider),
|
||||
URL: versionURL + "/" + url.PathEscape(f.File.Name),
|
||||
Checksum: f.Blob.HashSHA512,
|
||||
ChecksumType: "sha512",
|
||||
})
|
||||
}
|
||||
|
||||
return &versionMetadata{
|
||||
Status: "active",
|
||||
Version: pd.Version.Version,
|
||||
Providers: providers,
|
||||
}
|
||||
}
|
||||
|
||||
func EnumeratePackageVersions(ctx *context.Context) {
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeVagrant, ctx.PathParam("name"))
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pvs) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
|
||||
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
sort.Slice(pds, func(i, j int) bool {
|
||||
return pds[i].SemVer.LessThan(pds[j].SemVer)
|
||||
})
|
||||
|
||||
baseURL := fmt.Sprintf("%sapi/packages/%s/vagrant/%s", setting.AppURL, url.PathEscape(ctx.Package.Owner.Name), url.PathEscape(pds[0].Package.Name))
|
||||
|
||||
versions := make([]*versionMetadata, 0, len(pds))
|
||||
for _, pd := range pds {
|
||||
versions = append(versions, packageDescriptorToMetadata(baseURL, pd))
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, &packageMetadata{
|
||||
Name: pds[0].Package.Name,
|
||||
Description: pds[len(pds)-1].Metadata.(*vagrant_module.Metadata).Description,
|
||||
Versions: versions,
|
||||
})
|
||||
}
|
||||
|
||||
func UploadPackageFile(ctx *context.Context) {
|
||||
boxName := ctx.PathParam("name")
|
||||
boxVersion := ctx.PathParam("version")
|
||||
_, err := version.NewSemver(boxVersion)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
boxProvider := ctx.PathParam("provider")
|
||||
if !strings.HasSuffix(boxProvider, ".box") {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
upload, needsClose, err := ctx.UploadStream()
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if needsClose {
|
||||
defer upload.Close()
|
||||
}
|
||||
|
||||
buf, err := packages_module.CreateHashedBufferFromReader(upload)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer buf.Close()
|
||||
|
||||
metadata, err := vagrant_module.ParseMetadataFromBox(buf)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := buf.Seek(0, io.SeekStart); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
_, _, err = packages_service.CreatePackageOrAddFileToExisting(
|
||||
ctx,
|
||||
&packages_service.PackageCreationInfo{
|
||||
PackageInfo: packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeVagrant,
|
||||
Name: boxName,
|
||||
Version: boxVersion,
|
||||
},
|
||||
SemverCompatible: true,
|
||||
Creator: ctx.Doer,
|
||||
Metadata: metadata,
|
||||
},
|
||||
&packages_service.PackageFileCreationInfo{
|
||||
PackageFileInfo: packages_service.PackageFileInfo{
|
||||
Filename: strings.ToLower(boxProvider),
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Data: buf,
|
||||
IsLead: true,
|
||||
Properties: map[string]string{
|
||||
vagrant_module.PropertyProvider: strings.TrimSuffix(boxProvider, ".box"),
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case packages_model.ErrDuplicatePackageFile:
|
||||
apiError(ctx, http.StatusConflict, err)
|
||||
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
default:
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusCreated)
|
||||
}
|
||||
|
||||
func DownloadPackageFile(ctx *context.Context) {
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion(
|
||||
ctx,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeVagrant,
|
||||
Name: ctx.PathParam("name"),
|
||||
Version: ctx.PathParam("version"),
|
||||
},
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: ctx.PathParam("provider"),
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(err, packages_model.ErrPackageFileNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
106
routers/api/v1/activitypub/person.go
Normal file
106
routers/api/v1/activitypub/person.go
Normal file
@@ -0,0 +1,106 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package activitypub
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/activitypub"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
|
||||
ap "github.com/go-ap/activitypub"
|
||||
"github.com/go-ap/jsonld"
|
||||
)
|
||||
|
||||
// Person function returns the Person actor for a user
|
||||
func Person(ctx *context.APIContext) {
|
||||
// swagger:operation GET /activitypub/user-id/{user-id} activitypub activitypubPerson
|
||||
// ---
|
||||
// summary: Returns the Person actor for a user
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: user-id
|
||||
// in: path
|
||||
// description: user ID of the user
|
||||
// type: integer
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/ActivityPub"
|
||||
|
||||
// TODO: the setting.AppURL during the test doesn't follow the definition: "It always has a '/' suffix"
|
||||
link := fmt.Sprintf("%s/api/v1/activitypub/user-id/%d", strings.TrimSuffix(setting.AppURL, "/"), ctx.ContextUser.ID)
|
||||
person := ap.PersonNew(ap.IRI(link))
|
||||
|
||||
person.Name = ap.NaturalLanguageValuesNew()
|
||||
err := person.Name.Set("en", ap.Content(ctx.ContextUser.FullName))
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
person.PreferredUsername = ap.NaturalLanguageValuesNew()
|
||||
err = person.PreferredUsername.Set("en", ap.Content(ctx.ContextUser.Name))
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
person.URL = ap.IRI(ctx.ContextUser.HTMLURL(ctx))
|
||||
|
||||
person.Icon = ap.Image{
|
||||
Type: ap.ImageType,
|
||||
MediaType: "image/png",
|
||||
URL: ap.IRI(ctx.ContextUser.AvatarLink(ctx)),
|
||||
}
|
||||
|
||||
person.Inbox = ap.IRI(link + "/inbox")
|
||||
person.Outbox = ap.IRI(link + "/outbox")
|
||||
|
||||
person.PublicKey.ID = ap.IRI(link + "#main-key")
|
||||
person.PublicKey.Owner = ap.IRI(link)
|
||||
|
||||
publicKeyPem, err := activitypub.GetPublicKey(ctx, ctx.ContextUser)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
person.PublicKey.PublicKeyPem = publicKeyPem
|
||||
|
||||
binary, err := jsonld.WithContext(jsonld.IRI(ap.ActivityBaseURI), jsonld.IRI(ap.SecurityContextURI)).Marshal(person)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.Resp.Header().Add("Content-Type", activitypub.ActivityStreamsContentType)
|
||||
ctx.Resp.WriteHeader(http.StatusOK)
|
||||
if _, err = ctx.Resp.Write(binary); err != nil {
|
||||
log.Error("write to resp err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// PersonInbox function handles the incoming data for a user inbox
|
||||
func PersonInbox(ctx *context.APIContext) {
|
||||
// swagger:operation POST /activitypub/user-id/{user-id}/inbox activitypub activitypubPersonInbox
|
||||
// ---
|
||||
// summary: Send to the inbox
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: user-id
|
||||
// in: path
|
||||
// description: user ID of the user
|
||||
// type: integer
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
98
routers/api/v1/activitypub/reqsignature.go
Normal file
98
routers/api/v1/activitypub/reqsignature.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package activitypub
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"code.gitea.io/gitea/modules/activitypub"
|
||||
"code.gitea.io/gitea/modules/httplib"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
gitea_context "code.gitea.io/gitea/services/context"
|
||||
|
||||
"github.com/42wim/httpsig"
|
||||
ap "github.com/go-ap/activitypub"
|
||||
)
|
||||
|
||||
func getPublicKeyFromResponse(b []byte, keyID *url.URL) (p crypto.PublicKey, err error) {
|
||||
person := ap.PersonNew(ap.IRI(keyID.String()))
|
||||
err = person.UnmarshalJSON(b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ActivityStreams type cannot be converted to one known to have publicKey property: %w", err)
|
||||
}
|
||||
pubKey := person.PublicKey
|
||||
if pubKey.ID.String() != keyID.String() {
|
||||
return nil, fmt.Errorf("cannot find publicKey with id: %s in %s", keyID, string(b))
|
||||
}
|
||||
pubKeyPem := pubKey.PublicKeyPem
|
||||
block, _ := pem.Decode([]byte(pubKeyPem))
|
||||
if block == nil || block.Type != "PUBLIC KEY" {
|
||||
return nil, errors.New("could not decode publicKeyPem to PUBLIC KEY pem block type")
|
||||
}
|
||||
p, err = x509.ParsePKIXPublicKey(block.Bytes)
|
||||
return p, err
|
||||
}
|
||||
|
||||
func fetch(iri *url.URL) (b []byte, err error) {
|
||||
req := httplib.NewRequest(iri.String(), http.MethodGet)
|
||||
req.Header("Accept", activitypub.ActivityStreamsContentType)
|
||||
req.Header("User-Agent", "Gitea/"+setting.AppVer)
|
||||
resp, err := req.Response()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("url IRI fetch [%s] failed with status (%d): %s", iri, resp.StatusCode, resp.Status)
|
||||
}
|
||||
b, err = io.ReadAll(io.LimitReader(resp.Body, setting.Federation.MaxSize))
|
||||
return b, err
|
||||
}
|
||||
|
||||
func verifyHTTPSignatures(ctx *gitea_context.APIContext) (authenticated bool, err error) {
|
||||
r := ctx.Req
|
||||
|
||||
// 1. Figure out what key we need to verify
|
||||
v, err := httpsig.NewVerifier(r)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
ID := v.KeyId()
|
||||
idIRI, err := url.Parse(ID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// 2. Fetch the public key of the other actor
|
||||
b, err := fetch(idIRI)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
pubKey, err := getPublicKeyFromResponse(b, idIRI)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// 3. Verify the other actor's key
|
||||
algo := httpsig.Algorithm(setting.Federation.Algorithms[0])
|
||||
authenticated = v.Verify(pubKey, algo) == nil
|
||||
return authenticated, err
|
||||
}
|
||||
|
||||
// ReqHTTPSignature function
|
||||
func ReqHTTPSignature() func(ctx *gitea_context.APIContext) {
|
||||
return func(ctx *gitea_context.APIContext) {
|
||||
if authenticated, err := verifyHTTPSignatures(ctx); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
} else if !authenticated {
|
||||
ctx.APIError(http.StatusForbidden, "request signature verification failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
93
routers/api/v1/admin/action.go
Normal file
93
routers/api/v1/admin/action.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/routers/api/v1/shared"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
// ListWorkflowJobs Lists all jobs
|
||||
func ListWorkflowJobs(ctx *context.APIContext) {
|
||||
// swagger:operation GET /admin/actions/jobs admin listAdminWorkflowJobs
|
||||
// ---
|
||||
// summary: Lists all jobs
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: status
|
||||
// in: query
|
||||
// description: workflow status (pending, queued, in_progress, failure, success, skipped)
|
||||
// type: string
|
||||
// required: false
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/WorkflowJobsList"
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
shared.ListJobs(ctx, 0, 0, 0)
|
||||
}
|
||||
|
||||
// ListWorkflowRuns Lists all runs
|
||||
func ListWorkflowRuns(ctx *context.APIContext) {
|
||||
// swagger:operation GET /admin/actions/runs admin listAdminWorkflowRuns
|
||||
// ---
|
||||
// summary: Lists all runs
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: event
|
||||
// in: query
|
||||
// description: workflow event name
|
||||
// type: string
|
||||
// required: false
|
||||
// - name: branch
|
||||
// in: query
|
||||
// description: workflow branch
|
||||
// type: string
|
||||
// required: false
|
||||
// - name: status
|
||||
// in: query
|
||||
// description: workflow status (pending, queued, in_progress, failure, success, skipped)
|
||||
// type: string
|
||||
// required: false
|
||||
// - name: actor
|
||||
// in: query
|
||||
// description: triggered by user
|
||||
// type: string
|
||||
// required: false
|
||||
// - name: head_sha
|
||||
// in: query
|
||||
// description: triggering sha of the workflow run
|
||||
// type: string
|
||||
// required: false
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/WorkflowRunsList"
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
shared.ListRuns(ctx, 0, 0)
|
||||
}
|
||||
180
routers/api/v1/admin/adopt.go
Normal file
180
routers/api/v1/admin/adopt.go
Normal file
@@ -0,0 +1,180 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/routers/api/v1/utils"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
repo_service "code.gitea.io/gitea/services/repository"
|
||||
)
|
||||
|
||||
// ListUnadoptedRepositories lists the unadopted repositories that match the provided names
|
||||
func ListUnadoptedRepositories(ctx *context.APIContext) {
|
||||
// swagger:operation GET /admin/unadopted admin adminUnadoptedList
|
||||
// ---
|
||||
// summary: List unadopted repositories
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// - name: pattern
|
||||
// in: query
|
||||
// description: pattern of repositories to search for
|
||||
// type: string
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/StringSlice"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
if listOptions.Page == 0 {
|
||||
listOptions.Page = 1
|
||||
}
|
||||
repoNames, count, err := repo_service.ListUnadoptedRepositories(ctx, ctx.FormString("query"), &listOptions)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetTotalCountHeader(int64(count))
|
||||
|
||||
ctx.JSON(http.StatusOK, repoNames)
|
||||
}
|
||||
|
||||
// AdoptRepository will adopt an unadopted repository
|
||||
func AdoptRepository(ctx *context.APIContext) {
|
||||
// swagger:operation POST /admin/unadopted/{owner}/{repo} admin adminAdoptRepository
|
||||
// ---
|
||||
// summary: Adopt unadopted files as a repository
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
ownerName := ctx.PathParam("username")
|
||||
repoName := ctx.PathParam("reponame")
|
||||
|
||||
ctxUser, err := user_model.GetUserByName(ctx, ownerName)
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
// check not a repo
|
||||
has, err := repo_model.IsRepositoryModelExist(ctx, ctxUser, repoName)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
isDir, err := util.IsDir(repo_model.RepoPath(ctxUser.Name, repoName))
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if has || !isDir {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
if _, err := repo_service.AdoptRepository(ctx, ctx.Doer, ctxUser, repo_service.CreateRepoOptions{
|
||||
Name: repoName,
|
||||
IsPrivate: true,
|
||||
}); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// DeleteUnadoptedRepository will delete an unadopted repository
|
||||
func DeleteUnadoptedRepository(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /admin/unadopted/{owner}/{repo} admin adminDeleteUnadoptedRepository
|
||||
// ---
|
||||
// summary: Delete unadopted files
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
ownerName := ctx.PathParam("username")
|
||||
repoName := ctx.PathParam("reponame")
|
||||
|
||||
ctxUser, err := user_model.GetUserByName(ctx, ownerName)
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
// check not a repo
|
||||
has, err := repo_model.IsRepositoryModelExist(ctx, ctxUser, repoName)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
isDir, err := util.IsDir(repo_model.RepoPath(ctxUser.Name, repoName))
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if has || !isDir {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
if err := repo_service.DeleteUnadoptedRepository(ctx, ctx.Doer, ctxUser, repoName); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
86
routers/api/v1/admin/cron.go
Normal file
86
routers/api/v1/admin/cron.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/routers/api/v1/utils"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/cron"
|
||||
)
|
||||
|
||||
// ListCronTasks api for getting cron tasks
|
||||
func ListCronTasks(ctx *context.APIContext) {
|
||||
// swagger:operation GET /admin/cron admin adminCronList
|
||||
// ---
|
||||
// summary: List cron tasks
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/CronList"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
tasks := cron.ListTasks()
|
||||
count := len(tasks)
|
||||
|
||||
listOpts := utils.GetListOptions(ctx)
|
||||
tasks = util.PaginateSlice(tasks, listOpts.Page, listOpts.PageSize).(cron.TaskTable)
|
||||
|
||||
res := make([]structs.Cron, len(tasks))
|
||||
for i, task := range tasks {
|
||||
res[i] = structs.Cron{
|
||||
Name: task.Name,
|
||||
Schedule: task.Spec,
|
||||
Next: task.Next,
|
||||
Prev: task.Prev,
|
||||
ExecTimes: task.ExecTimes,
|
||||
}
|
||||
}
|
||||
|
||||
ctx.SetTotalCountHeader(int64(count))
|
||||
ctx.JSON(http.StatusOK, res)
|
||||
}
|
||||
|
||||
// PostCronTask api for getting cron tasks
|
||||
func PostCronTask(ctx *context.APIContext) {
|
||||
// swagger:operation POST /admin/cron/{task} admin adminCronRun
|
||||
// ---
|
||||
// summary: Run cron task
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: task
|
||||
// in: path
|
||||
// description: task to run
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
task := cron.GetTask(ctx.PathParam("task"))
|
||||
if task == nil {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
task.Run()
|
||||
log.Trace("Cron Task %s started by admin(%s)", task.Name, ctx.Doer.Name)
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
87
routers/api/v1/admin/email.go
Normal file
87
routers/api/v1/admin/email.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/routers/api/v1/utils"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/convert"
|
||||
)
|
||||
|
||||
// GetAllEmails
|
||||
func GetAllEmails(ctx *context.APIContext) {
|
||||
// swagger:operation GET /admin/emails admin adminGetAllEmails
|
||||
// ---
|
||||
// summary: List all emails
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/EmailList"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
|
||||
emails, maxResults, err := user_model.SearchEmails(ctx, &user_model.SearchEmailOptions{
|
||||
Keyword: ctx.PathParam("email"),
|
||||
ListOptions: listOptions,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
results := make([]*api.Email, len(emails))
|
||||
for i := range emails {
|
||||
results[i] = convert.ToEmailSearch(emails[i])
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(maxResults), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(maxResults)
|
||||
ctx.JSON(http.StatusOK, &results)
|
||||
}
|
||||
|
||||
// SearchEmail
|
||||
func SearchEmail(ctx *context.APIContext) {
|
||||
// swagger:operation GET /admin/emails/search admin adminSearchEmails
|
||||
// ---
|
||||
// summary: Search all emails
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: q
|
||||
// in: query
|
||||
// description: keyword
|
||||
// type: string
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/EmailList"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
|
||||
ctx.SetPathParam("email", ctx.FormTrim("q"))
|
||||
GetAllEmails(ctx)
|
||||
}
|
||||
197
routers/api/v1/admin/hooks.go
Normal file
197
routers/api/v1/admin/hooks.go
Normal file
@@ -0,0 +1,197 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/models/webhook"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/routers/api/v1/utils"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
webhook_service "code.gitea.io/gitea/services/webhook"
|
||||
)
|
||||
|
||||
// ListHooks list system's webhooks
|
||||
func ListHooks(ctx *context.APIContext) {
|
||||
// swagger:operation GET /admin/hooks admin adminListHooks
|
||||
// ---
|
||||
// summary: List system's webhooks
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// - type: string
|
||||
// enum:
|
||||
// - system
|
||||
// - default
|
||||
// - all
|
||||
// description: system, default or both kinds of webhooks
|
||||
// name: type
|
||||
// default: system
|
||||
// in: query
|
||||
//
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/HookList"
|
||||
|
||||
// for compatibility the default value is true
|
||||
isSystemWebhook := optional.Some(true)
|
||||
typeValue := ctx.FormString("type")
|
||||
switch typeValue {
|
||||
case "default":
|
||||
isSystemWebhook = optional.Some(false)
|
||||
case "all":
|
||||
isSystemWebhook = optional.None[bool]()
|
||||
}
|
||||
|
||||
sysHooks, err := webhook.GetSystemOrDefaultWebhooks(ctx, isSystemWebhook)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
hooks := make([]*api.Hook, len(sysHooks))
|
||||
for i, hook := range sysHooks {
|
||||
h, err := webhook_service.ToHook(setting.AppURL+"/-/admin", hook)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
hooks[i] = h
|
||||
}
|
||||
ctx.JSON(http.StatusOK, hooks)
|
||||
}
|
||||
|
||||
// GetHook get an organization's hook by id
|
||||
func GetHook(ctx *context.APIContext) {
|
||||
// swagger:operation GET /admin/hooks/{id} admin adminGetHook
|
||||
// ---
|
||||
// summary: Get a hook
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the hook to get
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/Hook"
|
||||
|
||||
hookID := ctx.PathParamInt64("id")
|
||||
hook, err := webhook.GetSystemOrDefaultWebhook(ctx, hookID)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
h, err := webhook_service.ToHook("/-/admin/", hook)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, h)
|
||||
}
|
||||
|
||||
// CreateHook create a hook for an organization
|
||||
func CreateHook(ctx *context.APIContext) {
|
||||
// swagger:operation POST /admin/hooks admin adminCreateHook
|
||||
// ---
|
||||
// summary: Create a hook
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: body
|
||||
// in: body
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/CreateHookOption"
|
||||
// responses:
|
||||
// "201":
|
||||
// "$ref": "#/responses/Hook"
|
||||
|
||||
form := web.GetForm(ctx).(*api.CreateHookOption)
|
||||
|
||||
utils.AddSystemHook(ctx, form)
|
||||
}
|
||||
|
||||
// EditHook modify a hook of a repository
|
||||
func EditHook(ctx *context.APIContext) {
|
||||
// swagger:operation PATCH /admin/hooks/{id} admin adminEditHook
|
||||
// ---
|
||||
// summary: Update a hook
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the hook to update
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/EditHookOption"
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/Hook"
|
||||
|
||||
form := web.GetForm(ctx).(*api.EditHookOption)
|
||||
|
||||
// TODO in body params
|
||||
hookID := ctx.PathParamInt64("id")
|
||||
utils.EditSystemHook(ctx, form, hookID)
|
||||
}
|
||||
|
||||
// DeleteHook delete a system hook
|
||||
func DeleteHook(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /admin/hooks/{id} admin adminDeleteHook
|
||||
// ---
|
||||
// summary: Delete a hook
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the hook to delete
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
|
||||
hookID := ctx.PathParamInt64("id")
|
||||
if err := webhook.DeleteDefaultSystemWebhook(ctx, hookID); err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
123
routers/api/v1/admin/org.go
Normal file
123
routers/api/v1/admin/org.go
Normal file
@@ -0,0 +1,123 @@
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/routers/api/v1/utils"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/convert"
|
||||
)
|
||||
|
||||
// CreateOrg api for create organization
|
||||
func CreateOrg(ctx *context.APIContext) {
|
||||
// swagger:operation POST /admin/users/{username}/orgs admin adminCreateOrg
|
||||
// ---
|
||||
// summary: Create an organization
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user who will own the created organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: organization
|
||||
// in: body
|
||||
// required: true
|
||||
// schema: { "$ref": "#/definitions/CreateOrgOption" }
|
||||
// responses:
|
||||
// "201":
|
||||
// "$ref": "#/responses/Organization"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
form := web.GetForm(ctx).(*api.CreateOrgOption)
|
||||
|
||||
visibility := api.VisibleTypePublic
|
||||
if form.Visibility != "" {
|
||||
visibility = api.VisibilityModes[form.Visibility]
|
||||
}
|
||||
|
||||
org := &organization.Organization{
|
||||
Name: form.UserName,
|
||||
FullName: form.FullName,
|
||||
Description: form.Description,
|
||||
Website: form.Website,
|
||||
Location: form.Location,
|
||||
IsActive: true,
|
||||
Type: user_model.UserTypeOrganization,
|
||||
Visibility: visibility,
|
||||
}
|
||||
|
||||
if err := organization.CreateOrganization(ctx, org, ctx.ContextUser); err != nil {
|
||||
if user_model.IsErrUserAlreadyExist(err) ||
|
||||
db.IsErrNameReserved(err) ||
|
||||
db.IsErrNameCharsNotAllowed(err) ||
|
||||
db.IsErrNamePatternNotAllowed(err) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusCreated, convert.ToOrganization(ctx, org))
|
||||
}
|
||||
|
||||
// GetAllOrgs API for getting information of all the organizations
|
||||
func GetAllOrgs(ctx *context.APIContext) {
|
||||
// swagger:operation GET /admin/orgs admin adminGetAllOrgs
|
||||
// ---
|
||||
// summary: List all organizations
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/OrganizationList"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
|
||||
users, maxResults, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{
|
||||
Actor: ctx.Doer,
|
||||
Type: user_model.UserTypeOrganization,
|
||||
OrderBy: db.SearchOrderByAlphabetically,
|
||||
ListOptions: listOptions,
|
||||
Visible: []api.VisibleType{api.VisibleTypePublic, api.VisibleTypeLimited, api.VisibleTypePrivate},
|
||||
})
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
orgs := make([]*api.Organization, len(users))
|
||||
for i := range users {
|
||||
orgs[i] = convert.ToOrganization(ctx, organization.OrgFromUser(users[i]))
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(maxResults), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(maxResults)
|
||||
ctx.JSON(http.StatusOK, &orgs)
|
||||
}
|
||||
49
routers/api/v1/admin/repo.go
Normal file
49
routers/api/v1/admin/repo.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/routers/api/v1/repo"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
// CreateRepo api for creating a repository
|
||||
func CreateRepo(ctx *context.APIContext) {
|
||||
// swagger:operation POST /admin/users/{username}/repos admin adminCreateRepo
|
||||
// ---
|
||||
// summary: Create a repository on behalf of a user
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user who will own the created repository
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repository
|
||||
// in: body
|
||||
// required: true
|
||||
// schema: { "$ref": "#/definitions/CreateRepoOption" }
|
||||
// responses:
|
||||
// "201":
|
||||
// "$ref": "#/responses/Repository"
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "409":
|
||||
// "$ref": "#/responses/error"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
form := web.GetForm(ctx).(*api.CreateRepoOption)
|
||||
|
||||
repo.CreateUserRepo(ctx, ctx.ContextUser, *form)
|
||||
}
|
||||
104
routers/api/v1/admin/runners.go
Normal file
104
routers/api/v1/admin/runners.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/routers/api/v1/shared"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
// https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#create-a-registration-token-for-an-organization
|
||||
|
||||
// GetRegistrationToken returns the token to register global runners
|
||||
func GetRegistrationToken(ctx *context.APIContext) {
|
||||
// swagger:operation GET /admin/runners/registration-token admin adminGetRunnerRegistrationToken
|
||||
// ---
|
||||
// summary: Get an global actions runner registration token
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/RegistrationToken"
|
||||
|
||||
shared.GetRegistrationToken(ctx, 0, 0)
|
||||
}
|
||||
|
||||
// CreateRegistrationToken returns the token to register global runners
|
||||
func CreateRegistrationToken(ctx *context.APIContext) {
|
||||
// swagger:operation POST /admin/actions/runners/registration-token admin adminCreateRunnerRegistrationToken
|
||||
// ---
|
||||
// summary: Get an global actions runner registration token
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/RegistrationToken"
|
||||
|
||||
shared.GetRegistrationToken(ctx, 0, 0)
|
||||
}
|
||||
|
||||
// ListRunners get all runners
|
||||
func ListRunners(ctx *context.APIContext) {
|
||||
// swagger:operation GET /admin/actions/runners admin getAdminRunners
|
||||
// ---
|
||||
// summary: Get all runners
|
||||
// produces:
|
||||
// - application/json
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/definitions/ActionRunnersResponse"
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
shared.ListRunners(ctx, 0, 0)
|
||||
}
|
||||
|
||||
// GetRunner get an global runner
|
||||
func GetRunner(ctx *context.APIContext) {
|
||||
// swagger:operation GET /admin/actions/runners/{runner_id} admin getAdminRunner
|
||||
// ---
|
||||
// summary: Get an global runner
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: runner_id
|
||||
// in: path
|
||||
// description: id of the runner
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/definitions/ActionRunner"
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
shared.GetRunner(ctx, 0, 0, ctx.PathParamInt64("runner_id"))
|
||||
}
|
||||
|
||||
// DeleteRunner delete an global runner
|
||||
func DeleteRunner(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /admin/actions/runners/{runner_id} admin deleteAdminRunner
|
||||
// ---
|
||||
// summary: Delete an global runner
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: runner_id
|
||||
// in: path
|
||||
// description: id of the runner
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// description: runner has been deleted
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
shared.DeleteRunner(ctx, 0, 0, ctx.PathParamInt64("runner_id"))
|
||||
}
|
||||
492
routers/api/v1/admin/user.go
Normal file
492
routers/api/v1/admin/user.go
Normal file
@@ -0,0 +1,492 @@
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
asymkey_model "code.gitea.io/gitea/models/asymkey"
|
||||
"code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
org_model "code.gitea.io/gitea/models/organization"
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/auth/password"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/routers/api/v1/user"
|
||||
"code.gitea.io/gitea/routers/api/v1/utils"
|
||||
asymkey_service "code.gitea.io/gitea/services/asymkey"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/convert"
|
||||
"code.gitea.io/gitea/services/mailer"
|
||||
user_service "code.gitea.io/gitea/services/user"
|
||||
)
|
||||
|
||||
func parseAuthSource(ctx *context.APIContext, u *user_model.User, sourceID int64) {
|
||||
if sourceID == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
source, err := auth.GetSourceByID(ctx, sourceID)
|
||||
if err != nil {
|
||||
if auth.IsErrSourceNotExist(err) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
u.LoginType = source.Type
|
||||
u.LoginSource = source.ID
|
||||
}
|
||||
|
||||
// CreateUser create a user
|
||||
func CreateUser(ctx *context.APIContext) {
|
||||
// swagger:operation POST /admin/users admin adminCreateUser
|
||||
// ---
|
||||
// summary: Create a user
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/CreateUserOption"
|
||||
// responses:
|
||||
// "201":
|
||||
// "$ref": "#/responses/User"
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
form := web.GetForm(ctx).(*api.CreateUserOption)
|
||||
|
||||
u := &user_model.User{
|
||||
Name: form.Username,
|
||||
FullName: form.FullName,
|
||||
Email: form.Email,
|
||||
Passwd: form.Password,
|
||||
MustChangePassword: true,
|
||||
LoginType: auth.Plain,
|
||||
LoginName: form.LoginName,
|
||||
}
|
||||
if form.MustChangePassword != nil {
|
||||
u.MustChangePassword = *form.MustChangePassword
|
||||
}
|
||||
|
||||
parseAuthSource(ctx, u, form.SourceID)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
if u.LoginType == auth.Plain {
|
||||
if len(form.Password) < setting.MinPasswordLength {
|
||||
err := errors.New("PasswordIsRequired")
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !password.IsComplexEnough(form.Password) {
|
||||
err := errors.New("PasswordComplexity")
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := password.IsPwned(ctx, form.Password); err != nil {
|
||||
if password.IsErrIsPwnedRequest(err) {
|
||||
log.Error(err.Error())
|
||||
}
|
||||
ctx.APIError(http.StatusBadRequest, errors.New("PasswordPwned"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
overwriteDefault := &user_model.CreateUserOverwriteOptions{
|
||||
IsActive: optional.Some(true),
|
||||
IsRestricted: optional.FromPtr(form.Restricted),
|
||||
}
|
||||
|
||||
if form.Visibility != "" {
|
||||
visibility := api.VisibilityModes[form.Visibility]
|
||||
overwriteDefault.Visibility = &visibility
|
||||
}
|
||||
|
||||
// Update the user creation timestamp. This can only be done after the user
|
||||
// record has been inserted into the database; the insert intself will always
|
||||
// set the creation timestamp to "now".
|
||||
if form.Created != nil {
|
||||
u.CreatedUnix = timeutil.TimeStamp(form.Created.Unix())
|
||||
u.UpdatedUnix = u.CreatedUnix
|
||||
}
|
||||
|
||||
if err := user_model.AdminCreateUser(ctx, u, &user_model.Meta{}, overwriteDefault); err != nil {
|
||||
if user_model.IsErrUserAlreadyExist(err) ||
|
||||
user_model.IsErrEmailAlreadyUsed(err) ||
|
||||
db.IsErrNameReserved(err) ||
|
||||
db.IsErrNameCharsNotAllowed(err) ||
|
||||
user_model.IsErrEmailCharIsNotSupported(err) ||
|
||||
user_model.IsErrEmailInvalid(err) ||
|
||||
db.IsErrNamePatternNotAllowed(err) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !user_model.IsEmailDomainAllowed(u.Email) {
|
||||
ctx.Resp.Header().Add("X-Gitea-Warning", fmt.Sprintf("the domain of user email %s conflicts with EMAIL_DOMAIN_ALLOWLIST or EMAIL_DOMAIN_BLOCKLIST", u.Email))
|
||||
}
|
||||
|
||||
log.Trace("Account created by admin (%s): %s", ctx.Doer.Name, u.Name)
|
||||
|
||||
// Send email notification.
|
||||
if form.SendNotify {
|
||||
mailer.SendRegisterNotifyMail(u)
|
||||
}
|
||||
ctx.JSON(http.StatusCreated, convert.ToUser(ctx, u, ctx.Doer))
|
||||
}
|
||||
|
||||
// EditUser api for modifying a user's information
|
||||
func EditUser(ctx *context.APIContext) {
|
||||
// swagger:operation PATCH /admin/users/{username} admin adminEditUser
|
||||
// ---
|
||||
// summary: Edit an existing user
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user whose data is to be edited
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/EditUserOption"
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/User"
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
form := web.GetForm(ctx).(*api.EditUserOption)
|
||||
|
||||
authOpts := &user_service.UpdateAuthOptions{
|
||||
LoginSource: optional.FromNonDefault(form.SourceID),
|
||||
LoginName: optional.Some(form.LoginName),
|
||||
Password: optional.FromNonDefault(form.Password),
|
||||
MustChangePassword: optional.FromPtr(form.MustChangePassword),
|
||||
ProhibitLogin: optional.FromPtr(form.ProhibitLogin),
|
||||
}
|
||||
if err := user_service.UpdateAuth(ctx, ctx.ContextUser, authOpts); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, password.ErrMinLength):
|
||||
ctx.APIError(http.StatusBadRequest, fmt.Errorf("password must be at least %d characters", setting.MinPasswordLength))
|
||||
case errors.Is(err, password.ErrComplexity):
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
case errors.Is(err, password.ErrIsPwned), password.IsErrIsPwnedRequest(err):
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
default:
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if form.Email != nil {
|
||||
if err := user_service.AdminAddOrSetPrimaryEmailAddress(ctx, ctx.ContextUser, *form.Email); err != nil {
|
||||
switch {
|
||||
case user_model.IsErrEmailCharIsNotSupported(err), user_model.IsErrEmailInvalid(err):
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
case user_model.IsErrEmailAlreadyUsed(err):
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
default:
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !user_model.IsEmailDomainAllowed(*form.Email) {
|
||||
ctx.Resp.Header().Add("X-Gitea-Warning", fmt.Sprintf("the domain of user email %s conflicts with EMAIL_DOMAIN_ALLOWLIST or EMAIL_DOMAIN_BLOCKLIST", *form.Email))
|
||||
}
|
||||
}
|
||||
|
||||
opts := &user_service.UpdateOptions{
|
||||
FullName: optional.FromPtr(form.FullName),
|
||||
Website: optional.FromPtr(form.Website),
|
||||
Location: optional.FromPtr(form.Location),
|
||||
Description: optional.FromPtr(form.Description),
|
||||
IsActive: optional.FromPtr(form.Active),
|
||||
IsAdmin: user_service.UpdateOptionFieldFromPtr(form.Admin),
|
||||
Visibility: optional.FromMapLookup(api.VisibilityModes, form.Visibility),
|
||||
AllowGitHook: optional.FromPtr(form.AllowGitHook),
|
||||
AllowImportLocal: optional.FromPtr(form.AllowImportLocal),
|
||||
MaxRepoCreation: optional.FromPtr(form.MaxRepoCreation),
|
||||
AllowCreateOrganization: optional.FromPtr(form.AllowCreateOrganization),
|
||||
IsRestricted: optional.FromPtr(form.Restricted),
|
||||
}
|
||||
|
||||
if err := user_service.UpdateUser(ctx, ctx.ContextUser, opts); err != nil {
|
||||
if user_model.IsErrDeleteLastAdminUser(err) {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Trace("Account profile updated by admin (%s): %s", ctx.Doer.Name, ctx.ContextUser.Name)
|
||||
|
||||
ctx.JSON(http.StatusOK, convert.ToUser(ctx, ctx.ContextUser, ctx.Doer))
|
||||
}
|
||||
|
||||
// DeleteUser api for deleting a user
|
||||
func DeleteUser(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /admin/users/{username} admin adminDeleteUser
|
||||
// ---
|
||||
// summary: Delete a user
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user to delete
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: purge
|
||||
// in: query
|
||||
// description: purge the user from the system completely
|
||||
// type: boolean
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
if ctx.ContextUser.IsOrganization() {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, fmt.Errorf("%s is an organization not a user", ctx.ContextUser.Name))
|
||||
return
|
||||
}
|
||||
|
||||
// admin should not delete themself
|
||||
if ctx.ContextUser.ID == ctx.Doer.ID {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, errors.New("you cannot delete yourself"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := user_service.DeleteUser(ctx, ctx.ContextUser, ctx.FormBool("purge")); err != nil {
|
||||
if repo_model.IsErrUserOwnRepos(err) ||
|
||||
org_model.IsErrUserHasOrgs(err) ||
|
||||
packages_model.IsErrUserOwnPackages(err) ||
|
||||
user_model.IsErrDeleteLastAdminUser(err) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Trace("Account deleted by admin(%s): %s", ctx.Doer.Name, ctx.ContextUser.Name)
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// CreatePublicKey api for creating a public key to a user
|
||||
func CreatePublicKey(ctx *context.APIContext) {
|
||||
// swagger:operation POST /admin/users/{username}/keys admin adminCreatePublicKey
|
||||
// ---
|
||||
// summary: Add a public key on behalf of a user
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user who is to receive a public key
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: key
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/CreateKeyOption"
|
||||
// responses:
|
||||
// "201":
|
||||
// "$ref": "#/responses/PublicKey"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
form := web.GetForm(ctx).(*api.CreateKeyOption)
|
||||
|
||||
user.CreateUserPublicKey(ctx, *form, ctx.ContextUser.ID)
|
||||
}
|
||||
|
||||
// DeleteUserPublicKey api for deleting a user's public key
|
||||
func DeleteUserPublicKey(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /admin/users/{username}/keys/{id} admin adminDeleteUserPublicKey
|
||||
// ---
|
||||
// summary: Delete a user's public key
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user whose public key is to be deleted
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the key to delete
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
if err := asymkey_service.DeletePublicKey(ctx, ctx.ContextUser, ctx.PathParamInt64("id")); err != nil {
|
||||
if asymkey_model.IsErrKeyNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else if asymkey_model.IsErrKeyAccessDenied(err) {
|
||||
ctx.APIError(http.StatusForbidden, "You do not have access to this key")
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Trace("Key deleted by admin(%s): %s", ctx.Doer.Name, ctx.ContextUser.Name)
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// SearchUsers API for getting information of the users according the filter conditions
|
||||
func SearchUsers(ctx *context.APIContext) {
|
||||
// swagger:operation GET /admin/users admin adminSearchUsers
|
||||
// ---
|
||||
// summary: Search users according filter conditions
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: source_id
|
||||
// in: query
|
||||
// description: ID of the user's login source to search for
|
||||
// type: integer
|
||||
// format: int64
|
||||
// - name: login_name
|
||||
// in: query
|
||||
// description: identifier of the user, provided by the external authenticator
|
||||
// type: string
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/UserList"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
|
||||
users, maxResults, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{
|
||||
Actor: ctx.Doer,
|
||||
Type: user_model.UserTypeIndividual,
|
||||
LoginName: ctx.FormTrim("login_name"),
|
||||
SourceID: ctx.FormInt64("source_id"),
|
||||
OrderBy: db.SearchOrderByAlphabetically,
|
||||
ListOptions: listOptions,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
results := make([]*api.User, len(users))
|
||||
for i := range users {
|
||||
results[i] = convert.ToUser(ctx, users[i], ctx.Doer)
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(maxResults), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(maxResults)
|
||||
ctx.JSON(http.StatusOK, &results)
|
||||
}
|
||||
|
||||
// RenameUser api for renaming a user
|
||||
func RenameUser(ctx *context.APIContext) {
|
||||
// swagger:operation POST /admin/users/{username}/rename admin adminRenameUser
|
||||
// ---
|
||||
// summary: Rename a user
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: current username of the user
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/RenameUserOption"
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
if ctx.ContextUser.IsOrganization() {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, fmt.Errorf("%s is an organization not a user", ctx.ContextUser.Name))
|
||||
return
|
||||
}
|
||||
|
||||
newName := web.GetForm(ctx).(*api.RenameUserOption).NewName
|
||||
|
||||
// Check if username has been changed
|
||||
if err := user_service.RenameUser(ctx, ctx.ContextUser, newName); err != nil {
|
||||
if user_model.IsErrUserAlreadyExist(err) || db.IsErrNameReserved(err) || db.IsErrNamePatternNotAllowed(err) || db.IsErrNameCharsNotAllowed(err) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
124
routers/api/v1/admin/user_badge.go
Normal file
124
routers/api/v1/admin/user_badge.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
// ListUserBadges lists all badges belonging to a user
|
||||
func ListUserBadges(ctx *context.APIContext) {
|
||||
// swagger:operation GET /admin/users/{username}/badges admin adminListUserBadges
|
||||
// ---
|
||||
// summary: List a user's badges
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user whose badges are to be listed
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/BadgeList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
badges, maxResults, err := user_model.GetUserBadges(ctx, ctx.ContextUser)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetTotalCountHeader(maxResults)
|
||||
ctx.JSON(http.StatusOK, &badges)
|
||||
}
|
||||
|
||||
// AddUserBadges add badges to a user
|
||||
func AddUserBadges(ctx *context.APIContext) {
|
||||
// swagger:operation POST /admin/users/{username}/badges admin adminAddUserBadges
|
||||
// ---
|
||||
// summary: Add a badge to a user
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user to whom a badge is to be added
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/UserBadgeOption"
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
|
||||
form := web.GetForm(ctx).(*api.UserBadgeOption)
|
||||
badges := prepareBadgesForReplaceOrAdd(*form)
|
||||
|
||||
if err := user_model.AddUserBadges(ctx, ctx.ContextUser, badges); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// DeleteUserBadges delete a badge from a user
|
||||
func DeleteUserBadges(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /admin/users/{username}/badges admin adminDeleteUserBadges
|
||||
// ---
|
||||
// summary: Remove a badge from a user
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user whose badge is to be deleted
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/UserBadgeOption"
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
form := web.GetForm(ctx).(*api.UserBadgeOption)
|
||||
badges := prepareBadgesForReplaceOrAdd(*form)
|
||||
|
||||
if err := user_model.RemoveUserBadges(ctx, ctx.ContextUser, badges); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func prepareBadgesForReplaceOrAdd(form api.UserBadgeOption) []*user_model.Badge {
|
||||
badges := make([]*user_model.Badge, len(form.BadgeSlugs))
|
||||
for i, badge := range form.BadgeSlugs {
|
||||
badges[i] = &user_model.Badge{
|
||||
Slug: badge,
|
||||
}
|
||||
}
|
||||
return badges
|
||||
}
|
||||
1785
routers/api/v1/api.go
Normal file
1785
routers/api/v1/api.go
Normal file
File diff suppressed because it is too large
Load Diff
56
routers/api/v1/misc/gitignore.go
Normal file
56
routers/api/v1/misc/gitignore.go
Normal file
@@ -0,0 +1,56 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package misc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/modules/options"
|
||||
repo_module "code.gitea.io/gitea/modules/repository"
|
||||
"code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
// Shows a list of all Gitignore templates
|
||||
func ListGitignoresTemplates(ctx *context.APIContext) {
|
||||
// swagger:operation GET /gitignore/templates miscellaneous listGitignoresTemplates
|
||||
// ---
|
||||
// summary: Returns a list of all gitignore templates
|
||||
// produces:
|
||||
// - application/json
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/GitignoreTemplateList"
|
||||
ctx.JSON(http.StatusOK, repo_module.Gitignores)
|
||||
}
|
||||
|
||||
// SHows information about a gitignore template
|
||||
func GetGitignoreTemplateInfo(ctx *context.APIContext) {
|
||||
// swagger:operation GET /gitignore/templates/{name} miscellaneous getGitignoreTemplateInfo
|
||||
// ---
|
||||
// summary: Returns information about a gitignore template
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: name
|
||||
// in: path
|
||||
// description: name of the template
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/GitignoreTemplateInfo"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
name := util.PathJoinRelX(ctx.PathParam("name"))
|
||||
|
||||
text, err := options.Gitignore(name)
|
||||
if err != nil {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, &structs.GitignoreTemplateInfo{Name: name, Source: string(text)})
|
||||
}
|
||||
60
routers/api/v1/misc/label_templates.go
Normal file
60
routers/api/v1/misc/label_templates.go
Normal file
@@ -0,0 +1,60 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package misc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
repo_module "code.gitea.io/gitea/modules/repository"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/convert"
|
||||
)
|
||||
|
||||
// Shows a list of all Label templates
|
||||
func ListLabelTemplates(ctx *context.APIContext) {
|
||||
// swagger:operation GET /label/templates miscellaneous listLabelTemplates
|
||||
// ---
|
||||
// summary: Returns a list of all label templates
|
||||
// produces:
|
||||
// - application/json
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/LabelTemplateList"
|
||||
result := make([]string, len(repo_module.LabelTemplateFiles))
|
||||
for i := range repo_module.LabelTemplateFiles {
|
||||
result[i] = repo_module.LabelTemplateFiles[i].DisplayName
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// Shows all labels in a template
|
||||
func GetLabelTemplate(ctx *context.APIContext) {
|
||||
// swagger:operation GET /label/templates/{name} miscellaneous getLabelTemplateInfo
|
||||
// ---
|
||||
// summary: Returns all labels in a template
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: name
|
||||
// in: path
|
||||
// description: name of the template
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/LabelTemplateInfo"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
name := util.PathJoinRelX(ctx.PathParam("name"))
|
||||
|
||||
labels, err := repo_module.LoadTemplateLabelsByDisplayName(name)
|
||||
if err != nil {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, convert.ToLabelTemplateList(labels))
|
||||
}
|
||||
75
routers/api/v1/misc/licenses.go
Normal file
75
routers/api/v1/misc/licenses.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package misc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"code.gitea.io/gitea/modules/options"
|
||||
repo_module "code.gitea.io/gitea/modules/repository"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
// Returns a list of all License templates
|
||||
func ListLicenseTemplates(ctx *context.APIContext) {
|
||||
// swagger:operation GET /licenses miscellaneous listLicenseTemplates
|
||||
// ---
|
||||
// summary: Returns a list of all license templates
|
||||
// produces:
|
||||
// - application/json
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/LicenseTemplateList"
|
||||
response := make([]api.LicensesTemplateListEntry, len(repo_module.Licenses))
|
||||
for i, license := range repo_module.Licenses {
|
||||
response[i] = api.LicensesTemplateListEntry{
|
||||
Key: license,
|
||||
Name: license,
|
||||
URL: fmt.Sprintf("%sapi/v1/licenses/%s", setting.AppURL, url.PathEscape(license)),
|
||||
}
|
||||
}
|
||||
ctx.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func GetLicenseTemplateInfo(ctx *context.APIContext) {
|
||||
// swagger:operation GET /licenses/{name} miscellaneous getLicenseTemplateInfo
|
||||
// ---
|
||||
// summary: Returns information about a license template
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: name
|
||||
// in: path
|
||||
// description: name of the license
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/LicenseTemplateInfo"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
name := util.PathJoinRelX(ctx.PathParam("name"))
|
||||
|
||||
text, err := options.License(name)
|
||||
if err != nil {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
response := api.LicenseTemplateInfo{
|
||||
Key: name,
|
||||
Name: name,
|
||||
URL: fmt.Sprintf("%sapi/v1/licenses/%s", setting.AppURL, url.PathEscape(name)),
|
||||
Body: string(text),
|
||||
// This is for combatibilty with the GitHub API. This Text is for some reason added to each License response.
|
||||
Implementation: "Create a text file (typically named LICENSE or LICENSE.txt) in the root of your source code and copy the text of the license into the file",
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, response)
|
||||
}
|
||||
106
routers/api/v1/misc/markup.go
Normal file
106
routers/api/v1/misc/markup.go
Normal file
@@ -0,0 +1,106 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package misc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/markup/markdown"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/routers/common"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
// Markup render markup document to HTML
|
||||
func Markup(ctx *context.APIContext) {
|
||||
// swagger:operation POST /markup miscellaneous renderMarkup
|
||||
// ---
|
||||
// summary: Render a markup document as HTML
|
||||
// parameters:
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/MarkupOption"
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - text/html
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/MarkupRender"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
form := web.GetForm(ctx).(*api.MarkupOption)
|
||||
|
||||
if ctx.HasAPIError() {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, ctx.GetErrMsg())
|
||||
return
|
||||
}
|
||||
|
||||
mode := util.Iif(form.Wiki, "wiki", form.Mode) //nolint:staticcheck // form.Wiki is deprecated
|
||||
common.RenderMarkup(ctx.Base, ctx.Repo, mode, form.Text, form.Context, form.FilePath)
|
||||
}
|
||||
|
||||
// Markdown render markdown document to HTML
|
||||
func Markdown(ctx *context.APIContext) {
|
||||
// swagger:operation POST /markdown miscellaneous renderMarkdown
|
||||
// ---
|
||||
// summary: Render a markdown document as HTML
|
||||
// parameters:
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/MarkdownOption"
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - text/html
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/MarkdownRender"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
form := web.GetForm(ctx).(*api.MarkdownOption)
|
||||
|
||||
if ctx.HasAPIError() {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, ctx.GetErrMsg())
|
||||
return
|
||||
}
|
||||
|
||||
mode := util.Iif(form.Wiki, "wiki", form.Mode) //nolint:staticcheck // form.Wiki is deprecated
|
||||
common.RenderMarkup(ctx.Base, ctx.Repo, mode, form.Text, form.Context, "")
|
||||
}
|
||||
|
||||
// MarkdownRaw render raw markdown HTML
|
||||
func MarkdownRaw(ctx *context.APIContext) {
|
||||
// swagger:operation POST /markdown/raw miscellaneous renderMarkdownRaw
|
||||
// ---
|
||||
// summary: Render raw markdown as HTML
|
||||
// parameters:
|
||||
// - name: body
|
||||
// in: body
|
||||
// description: Request body to render
|
||||
// required: true
|
||||
// schema:
|
||||
// type: string
|
||||
// consumes:
|
||||
// - text/plain
|
||||
// produces:
|
||||
// - text/html
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/MarkdownRender"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
defer ctx.Req.Body.Close()
|
||||
if err := markdown.RenderRaw(markup.NewRenderContext(ctx), ctx.Req.Body, ctx.Resp); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
225
routers/api/v1/misc/markup_test.go
Normal file
225
routers/api/v1/misc/markup_test.go
Normal file
@@ -0,0 +1,225 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package misc
|
||||
|
||||
import (
|
||||
go_context "context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/test"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
context_service "code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/contexttest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const AppURL = "http://localhost:3000/"
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m, &unittest.TestOptions{
|
||||
FixtureFiles: []string{"repository.yml", "user.yml"},
|
||||
})
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func testRenderMarkup(t *testing.T, mode string, wiki bool, filePath, text, expectedBody string, expectedCode int) {
|
||||
setting.AppURL = AppURL
|
||||
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
||||
context := "/user2/repo1"
|
||||
if !wiki {
|
||||
context += path.Join("/src/branch/main", path.Dir(filePath))
|
||||
}
|
||||
options := api.MarkupOption{
|
||||
Mode: mode,
|
||||
Text: text,
|
||||
Context: context,
|
||||
Wiki: wiki,
|
||||
FilePath: filePath,
|
||||
}
|
||||
ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markup")
|
||||
ctx.Repo = &context_service.Repository{}
|
||||
ctx.Repo.Repository = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
web.SetForm(ctx, &options)
|
||||
Markup(ctx)
|
||||
assert.Equal(t, expectedBody, resp.Body.String())
|
||||
assert.Equal(t, expectedCode, resp.Code)
|
||||
resp.Body.Reset()
|
||||
}
|
||||
|
||||
func testRenderMarkdown(t *testing.T, mode string, wiki bool, text, responseBody string, responseCode int) {
|
||||
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
||||
setting.AppURL = AppURL
|
||||
context := "/user2/repo1"
|
||||
if !wiki {
|
||||
context += "/src/branch/main"
|
||||
}
|
||||
options := api.MarkdownOption{
|
||||
Mode: mode,
|
||||
Text: text,
|
||||
Context: context,
|
||||
Wiki: wiki,
|
||||
}
|
||||
ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown")
|
||||
web.SetForm(ctx, &options)
|
||||
Markdown(ctx)
|
||||
assert.Equal(t, responseBody, resp.Body.String())
|
||||
assert.Equal(t, responseCode, resp.Code)
|
||||
resp.Body.Reset()
|
||||
}
|
||||
|
||||
func TestAPI_RenderGFM(t *testing.T) {
|
||||
unittest.PrepareTestEnv(t)
|
||||
markup.Init(&markup.RenderHelperFuncs{
|
||||
IsUsernameMentionable: func(ctx go_context.Context, username string) bool {
|
||||
return username == "r-lyeh"
|
||||
},
|
||||
})
|
||||
|
||||
testCasesWiki := []string{
|
||||
// dear imgui wiki markdown extract: special wiki syntax
|
||||
`Wiki! Enjoy :)
|
||||
- [[Links, Language bindings, Engine bindings|Links]]
|
||||
- [[Tips]]
|
||||
- Bezier widget (by @r-lyeh) https://github.com/ocornut/imgui/issues/786`,
|
||||
// rendered
|
||||
`<p>Wiki! Enjoy :)</p>
|
||||
<ul>
|
||||
<li><a href="http://localhost:3000/user2/repo1/wiki/Links" rel="nofollow">Links, Language bindings, Engine bindings</a></li>
|
||||
<li><a href="http://localhost:3000/user2/repo1/wiki/Tips" rel="nofollow">Tips</a></li>
|
||||
<li>Bezier widget (by <a href="http://localhost:3000/r-lyeh" rel="nofollow">@r-lyeh</a>) <a href="https://github.com/ocornut/imgui/issues/786" rel="nofollow">https://github.com/ocornut/imgui/issues/786</a></li>
|
||||
</ul>
|
||||
`,
|
||||
// Guard wiki sidebar: special syntax
|
||||
`[[Guardfile-DSL / Configuring-Guard|Guardfile-DSL---Configuring-Guard]]`,
|
||||
// rendered
|
||||
`<p><a href="http://localhost:3000/user2/repo1/wiki/Guardfile-DSL---Configuring-Guard" rel="nofollow">Guardfile-DSL / Configuring-Guard</a></p>
|
||||
`,
|
||||
// special syntax
|
||||
`[[Name|Link]]`,
|
||||
// rendered
|
||||
`<p><a href="http://localhost:3000/user2/repo1/wiki/Link" rel="nofollow">Name</a></p>
|
||||
`,
|
||||
// empty
|
||||
``,
|
||||
// rendered
|
||||
``,
|
||||
}
|
||||
|
||||
testCasesWikiDocument := []string{
|
||||
// wine-staging wiki home extract: special wiki syntax, images
|
||||
`## What is Wine Staging?
|
||||
**Wine Staging** on website [wine-staging.com](http://wine-staging.com).
|
||||
|
||||
## Quick Links
|
||||
Here are some links to the most important topics. You can find the full list of pages at the sidebar.
|
||||
|
||||
[[Configuration]]
|
||||
[[images/icon-bug.png]]
|
||||
`,
|
||||
// rendered
|
||||
`<h2 id="user-content-what-is-wine-staging">What is Wine Staging?</h2>
|
||||
<p><strong>Wine Staging</strong> on website <a href="http://wine-staging.com" rel="nofollow">wine-staging.com</a>.</p>
|
||||
<h2 id="user-content-quick-links">Quick Links</h2>
|
||||
<p>Here are some links to the most important topics. You can find the full list of pages at the sidebar.</p>
|
||||
<p><a href="http://localhost:3000/user2/repo1/wiki/Configuration" rel="nofollow">Configuration</a>
|
||||
<a href="http://localhost:3000/user2/repo1/wiki/images/icon-bug.png" rel="nofollow"><img src="http://localhost:3000/user2/repo1/wiki/raw/images/icon-bug.png" title="icon-bug.png" alt="images/icon-bug.png"/></a></p>
|
||||
`,
|
||||
}
|
||||
|
||||
for i := 0; i < len(testCasesWiki); i += 2 {
|
||||
text := testCasesWiki[i]
|
||||
response := testCasesWiki[i+1]
|
||||
testRenderMarkdown(t, "gfm", true, text, response, http.StatusOK)
|
||||
testRenderMarkup(t, "gfm", true, "", text, response, http.StatusOK)
|
||||
testRenderMarkdown(t, "comment", true, text, response, http.StatusOK)
|
||||
testRenderMarkup(t, "comment", true, "", text, response, http.StatusOK)
|
||||
testRenderMarkup(t, "file", true, "path/test.md", text, response, http.StatusOK)
|
||||
}
|
||||
|
||||
for i := 0; i < len(testCasesWikiDocument); i += 2 {
|
||||
text := testCasesWikiDocument[i]
|
||||
response := testCasesWikiDocument[i+1]
|
||||
testRenderMarkdown(t, "gfm", true, text, response, http.StatusOK)
|
||||
testRenderMarkup(t, "gfm", true, "", text, response, http.StatusOK)
|
||||
testRenderMarkup(t, "file", true, "path/test.md", text, response, http.StatusOK)
|
||||
}
|
||||
|
||||
input := "[Link](test.md)\n"
|
||||
testRenderMarkdown(t, "gfm", false, input, `<p><a href="http://localhost:3000/user2/repo1/src/branch/main/test.md" rel="nofollow">Link</a>
|
||||
<a href="http://localhost:3000/user2/repo1/src/branch/main/image.png" target="_blank" rel="nofollow noopener"><img src="http://localhost:3000/user2/repo1/media/branch/main/image.png" alt="Image"/></a></p>
|
||||
`, http.StatusOK)
|
||||
|
||||
testRenderMarkdown(t, "gfm", false, input, `<p><a href="http://localhost:3000/user2/repo1/src/branch/main/test.md" rel="nofollow">Link</a>
|
||||
<a href="http://localhost:3000/user2/repo1/src/branch/main/image.png" target="_blank" rel="nofollow noopener"><img src="http://localhost:3000/user2/repo1/media/branch/main/image.png" alt="Image"/></a></p>
|
||||
`, http.StatusOK)
|
||||
|
||||
testRenderMarkup(t, "gfm", false, "", input, `<p><a href="http://localhost:3000/user2/repo1/src/branch/main/test.md" rel="nofollow">Link</a>
|
||||
<a href="http://localhost:3000/user2/repo1/src/branch/main/image.png" target="_blank" rel="nofollow noopener"><img src="http://localhost:3000/user2/repo1/media/branch/main/image.png" alt="Image"/></a></p>
|
||||
`, http.StatusOK)
|
||||
|
||||
testRenderMarkup(t, "file", false, "path/new-file.md", input, `<p><a href="http://localhost:3000/user2/repo1/src/branch/main/path/test.md" rel="nofollow">Link</a>
|
||||
<a href="http://localhost:3000/user2/repo1/src/branch/main/path/image.png" target="_blank" rel="nofollow noopener"><img src="http://localhost:3000/user2/repo1/media/branch/main/path/image.png" alt="Image"/></a></p>
|
||||
`, http.StatusOK)
|
||||
|
||||
testRenderMarkup(t, "file", false, "path/test.unknown", "## Test", "unsupported file to render: \"path/test.unknown\"\n", http.StatusUnprocessableEntity)
|
||||
testRenderMarkup(t, "unknown", false, "", "## Test", "Unknown mode: unknown\n", http.StatusUnprocessableEntity)
|
||||
}
|
||||
|
||||
var simpleCases = []string{
|
||||
// Guard wiki sidebar: special syntax
|
||||
`[[Guardfile-DSL / Configuring-Guard|Guardfile-DSL---Configuring-Guard]]`,
|
||||
// rendered
|
||||
`<p>[[Guardfile-DSL / Configuring-Guard|Guardfile-DSL---Configuring-Guard]]</p>
|
||||
`,
|
||||
// special syntax
|
||||
`[[Name|Link]]`,
|
||||
// rendered
|
||||
`<p>[[Name|Link]]</p>
|
||||
`,
|
||||
// empty
|
||||
``,
|
||||
// rendered
|
||||
``,
|
||||
}
|
||||
|
||||
func TestAPI_RenderSimple(t *testing.T) {
|
||||
setting.AppURL = AppURL
|
||||
markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true
|
||||
options := api.MarkdownOption{
|
||||
Mode: "markdown",
|
||||
Text: "",
|
||||
Context: "/user2/repo1",
|
||||
}
|
||||
ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown")
|
||||
for i := 0; i < len(simpleCases); i += 2 {
|
||||
options.Text = simpleCases[i]
|
||||
web.SetForm(ctx, &options)
|
||||
Markdown(ctx)
|
||||
assert.Equal(t, simpleCases[i+1], resp.Body.String())
|
||||
resp.Body.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_RenderRaw(t *testing.T) {
|
||||
setting.AppURL = AppURL
|
||||
markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true
|
||||
ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown")
|
||||
for i := 0; i < len(simpleCases); i += 2 {
|
||||
ctx.Req.Body = io.NopCloser(strings.NewReader(simpleCases[i]))
|
||||
MarkdownRaw(ctx)
|
||||
assert.Equal(t, simpleCases[i+1], resp.Body.String())
|
||||
resp.Body.Reset()
|
||||
}
|
||||
}
|
||||
78
routers/api/v1/misc/nodeinfo.go
Normal file
78
routers/api/v1/misc/nodeinfo.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package misc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
const cacheKeyNodeInfoUsage = "API_NodeInfoUsage"
|
||||
|
||||
// NodeInfo returns the NodeInfo for the Gitea instance to allow for federation
|
||||
func NodeInfo(ctx *context.APIContext) {
|
||||
// swagger:operation GET /nodeinfo miscellaneous getNodeInfo
|
||||
// ---
|
||||
// summary: Returns the nodeinfo of the Gitea application
|
||||
// produces:
|
||||
// - application/json
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/NodeInfo"
|
||||
|
||||
nodeInfoUsage := structs.NodeInfoUsage{}
|
||||
if setting.Federation.ShareUserStatistics {
|
||||
cached, _ := ctx.Cache.GetJSON(cacheKeyNodeInfoUsage, &nodeInfoUsage)
|
||||
if !cached {
|
||||
usersTotal := int(user_model.CountUsers(ctx, nil))
|
||||
now := time.Now()
|
||||
timeOneMonthAgo := now.AddDate(0, -1, 0).Unix()
|
||||
timeHaveYearAgo := now.AddDate(0, -6, 0).Unix()
|
||||
usersActiveMonth := int(user_model.CountUsers(ctx, &user_model.CountUserFilter{LastLoginSince: &timeOneMonthAgo}))
|
||||
usersActiveHalfyear := int(user_model.CountUsers(ctx, &user_model.CountUserFilter{LastLoginSince: &timeHaveYearAgo}))
|
||||
|
||||
allIssues, _ := issues_model.CountIssues(ctx, &issues_model.IssuesOptions{})
|
||||
allComments, _ := issues_model.CountComments(ctx, &issues_model.FindCommentsOptions{})
|
||||
|
||||
nodeInfoUsage = structs.NodeInfoUsage{
|
||||
Users: structs.NodeInfoUsageUsers{
|
||||
Total: usersTotal,
|
||||
ActiveMonth: usersActiveMonth,
|
||||
ActiveHalfyear: usersActiveHalfyear,
|
||||
},
|
||||
LocalPosts: int(allIssues),
|
||||
LocalComments: int(allComments),
|
||||
}
|
||||
|
||||
if err := ctx.Cache.PutJSON(cacheKeyNodeInfoUsage, nodeInfoUsage, 180); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nodeInfo := &structs.NodeInfo{
|
||||
Version: "2.1",
|
||||
Software: structs.NodeInfoSoftware{
|
||||
Name: "gitea",
|
||||
Version: setting.AppVer,
|
||||
Repository: "https://github.com/go-gitea/gitea.git",
|
||||
Homepage: "https://gitea.io/",
|
||||
},
|
||||
Protocols: []string{"activitypub"},
|
||||
Services: structs.NodeInfoServices{
|
||||
Inbound: []string{},
|
||||
Outbound: []string{"rss2.0"},
|
||||
},
|
||||
OpenRegistrations: setting.Service.ShowRegistrationButton,
|
||||
Usage: nodeInfoUsage,
|
||||
}
|
||||
ctx.JSON(http.StatusOK, nodeInfo)
|
||||
}
|
||||
106
routers/api/v1/misc/signing.go
Normal file
106
routers/api/v1/misc/signing.go
Normal file
@@ -0,0 +1,106 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package misc
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
asymkey_service "code.gitea.io/gitea/services/asymkey"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
func getSigningKey(ctx *context.APIContext, expectedFormat string) {
|
||||
// if the handler is in the repo's route group, get the repo's signing key
|
||||
// otherwise, get the global signing key
|
||||
path := ""
|
||||
if ctx.Repo != nil && ctx.Repo.Repository != nil {
|
||||
path = ctx.Repo.Repository.RepoPath()
|
||||
}
|
||||
content, format, err := asymkey_service.PublicSigningKey(ctx, path)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if format == "" {
|
||||
ctx.APIErrorNotFound("no signing key")
|
||||
return
|
||||
} else if format != expectedFormat {
|
||||
ctx.APIErrorNotFound("signing key format is " + format)
|
||||
return
|
||||
}
|
||||
_, _ = ctx.Write([]byte(content))
|
||||
}
|
||||
|
||||
// SigningKeyGPG returns the public key of the default signing key if it exists
|
||||
func SigningKeyGPG(ctx *context.APIContext) {
|
||||
// swagger:operation GET /signing-key.gpg miscellaneous getSigningKey
|
||||
// ---
|
||||
// summary: Get default signing-key.gpg
|
||||
// produces:
|
||||
// - text/plain
|
||||
// responses:
|
||||
// "200":
|
||||
// description: "GPG armored public key"
|
||||
// schema:
|
||||
// type: string
|
||||
|
||||
// swagger:operation GET /repos/{owner}/{repo}/signing-key.gpg repository repoSigningKey
|
||||
// ---
|
||||
// summary: Get signing-key.gpg for given repository
|
||||
// produces:
|
||||
// - text/plain
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// description: "GPG armored public key"
|
||||
// schema:
|
||||
// type: string
|
||||
getSigningKey(ctx, git.SigningKeyFormatOpenPGP)
|
||||
}
|
||||
|
||||
// SigningKeySSH returns the public key of the default signing key if it exists
|
||||
func SigningKeySSH(ctx *context.APIContext) {
|
||||
// swagger:operation GET /signing-key.pub miscellaneous getSigningKeySSH
|
||||
// ---
|
||||
// summary: Get default signing-key.pub
|
||||
// produces:
|
||||
// - text/plain
|
||||
// responses:
|
||||
// "200":
|
||||
// description: "ssh public key"
|
||||
// schema:
|
||||
// type: string
|
||||
|
||||
// swagger:operation GET /repos/{owner}/{repo}/signing-key.pub repository repoSigningKeySSH
|
||||
// ---
|
||||
// summary: Get signing-key.pub for given repository
|
||||
// produces:
|
||||
// - text/plain
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// description: "ssh public key"
|
||||
// schema:
|
||||
// type: string
|
||||
getSigningKey(ctx, git.SigningKeyFormatSSH)
|
||||
}
|
||||
25
routers/api/v1/misc/version.go
Normal file
25
routers/api/v1/misc/version.go
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package misc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
// Version shows the version of the Gitea server
|
||||
func Version(ctx *context.APIContext) {
|
||||
// swagger:operation GET /version miscellaneous getVersion
|
||||
// ---
|
||||
// summary: Returns the version of the Gitea application
|
||||
// produces:
|
||||
// - application/json
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/ServerVersion"
|
||||
ctx.JSON(http.StatusOK, &structs.ServerVersion{Version: setting.AppVer})
|
||||
}
|
||||
77
routers/api/v1/notify/notifications.go
Normal file
77
routers/api/v1/notify/notifications.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package notify
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
activities_model "code.gitea.io/gitea/models/activities"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/routers/api/v1/utils"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
// NewAvailable check if unread notifications exist
|
||||
func NewAvailable(ctx *context.APIContext) {
|
||||
// swagger:operation GET /notifications/new notification notifyNewAvailable
|
||||
// ---
|
||||
// summary: Check if unread notifications exist
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/NotificationCount"
|
||||
|
||||
total, err := db.Count[activities_model.Notification](ctx, activities_model.FindNotificationOptions{
|
||||
UserID: ctx.Doer.ID,
|
||||
Status: []activities_model.NotificationStatus{activities_model.NotificationStatusUnread},
|
||||
})
|
||||
if err != nil {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, api.NotificationCount{New: total})
|
||||
}
|
||||
|
||||
func getFindNotificationOptions(ctx *context.APIContext) *activities_model.FindNotificationOptions {
|
||||
before, since, err := context.GetQueryBeforeSince(ctx.Base)
|
||||
if err != nil {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
return nil
|
||||
}
|
||||
opts := &activities_model.FindNotificationOptions{
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
UserID: ctx.Doer.ID,
|
||||
UpdatedBeforeUnix: before,
|
||||
UpdatedAfterUnix: since,
|
||||
}
|
||||
if !ctx.FormBool("all") {
|
||||
statuses := ctx.FormStrings("status-types")
|
||||
opts.Status = statusStringsToNotificationStatuses(statuses, []string{"unread", "pinned"})
|
||||
}
|
||||
|
||||
subjectTypes := ctx.FormStrings("subject-type")
|
||||
if len(subjectTypes) != 0 {
|
||||
opts.Source = subjectToSource(subjectTypes)
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
func subjectToSource(value []string) (result []activities_model.NotificationSource) {
|
||||
for _, v := range value {
|
||||
switch strings.ToLower(v) {
|
||||
case "issue":
|
||||
result = append(result, activities_model.NotificationSourceIssue)
|
||||
case "pull":
|
||||
result = append(result, activities_model.NotificationSourcePullRequest)
|
||||
case "commit":
|
||||
result = append(result, activities_model.NotificationSourceCommit)
|
||||
case "repository":
|
||||
result = append(result, activities_model.NotificationSourceRepository)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
227
routers/api/v1/notify/repo.go
Normal file
227
routers/api/v1/notify/repo.go
Normal file
@@ -0,0 +1,227 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package notify
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
activities_model "code.gitea.io/gitea/models/activities"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/convert"
|
||||
)
|
||||
|
||||
func statusStringToNotificationStatus(status string) activities_model.NotificationStatus {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "unread":
|
||||
return activities_model.NotificationStatusUnread
|
||||
case "read":
|
||||
return activities_model.NotificationStatusRead
|
||||
case "pinned":
|
||||
return activities_model.NotificationStatusPinned
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func statusStringsToNotificationStatuses(statuses, defaultStatuses []string) []activities_model.NotificationStatus {
|
||||
if len(statuses) == 0 {
|
||||
statuses = defaultStatuses
|
||||
}
|
||||
results := make([]activities_model.NotificationStatus, 0, len(statuses))
|
||||
for _, status := range statuses {
|
||||
notificationStatus := statusStringToNotificationStatus(status)
|
||||
if notificationStatus > 0 {
|
||||
results = append(results, notificationStatus)
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// ListRepoNotifications list users's notification threads on a specific repo
|
||||
func ListRepoNotifications(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/notifications notification notifyGetRepoList
|
||||
// ---
|
||||
// summary: List users's notification threads on a specific repo
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: all
|
||||
// in: query
|
||||
// description: If true, show notifications marked as read. Default value is false
|
||||
// type: boolean
|
||||
// - name: status-types
|
||||
// in: query
|
||||
// description: "Show notifications with the provided status types. Options are: unread, read and/or pinned. Defaults to unread & pinned"
|
||||
// type: array
|
||||
// collectionFormat: multi
|
||||
// items:
|
||||
// type: string
|
||||
// - name: subject-type
|
||||
// in: query
|
||||
// description: "filter notifications by subject type"
|
||||
// type: array
|
||||
// collectionFormat: multi
|
||||
// items:
|
||||
// type: string
|
||||
// enum: [issue,pull,commit,repository]
|
||||
// - name: since
|
||||
// in: query
|
||||
// description: Only show notifications updated after the given time. This is a timestamp in RFC 3339 format
|
||||
// type: string
|
||||
// format: date-time
|
||||
// - name: before
|
||||
// in: query
|
||||
// description: Only show notifications updated before the given time. This is a timestamp in RFC 3339 format
|
||||
// type: string
|
||||
// format: date-time
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/NotificationThreadList"
|
||||
opts := getFindNotificationOptions(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
opts.RepoID = ctx.Repo.Repository.ID
|
||||
|
||||
totalCount, err := db.Count[activities_model.Notification](ctx, opts)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
nl, err := db.Find[activities_model.Notification](ctx, opts)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
err = activities_model.NotificationList(nl).LoadAttributes(ctx)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetTotalCountHeader(totalCount)
|
||||
|
||||
ctx.JSON(http.StatusOK, convert.ToNotifications(ctx, nl))
|
||||
}
|
||||
|
||||
// ReadRepoNotifications mark notification threads as read on a specific repo
|
||||
func ReadRepoNotifications(ctx *context.APIContext) {
|
||||
// swagger:operation PUT /repos/{owner}/{repo}/notifications notification notifyReadRepoList
|
||||
// ---
|
||||
// summary: Mark notification threads as read, pinned or unread on a specific repo
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: all
|
||||
// in: query
|
||||
// description: If true, mark all notifications on this repo. Default value is false
|
||||
// type: string
|
||||
// required: false
|
||||
// - name: status-types
|
||||
// in: query
|
||||
// description: "Mark notifications with the provided status types. Options are: unread, read and/or pinned. Defaults to unread."
|
||||
// type: array
|
||||
// collectionFormat: multi
|
||||
// items:
|
||||
// type: string
|
||||
// required: false
|
||||
// - name: to-status
|
||||
// in: query
|
||||
// description: Status to mark notifications as. Defaults to read.
|
||||
// type: string
|
||||
// required: false
|
||||
// - name: last_read_at
|
||||
// in: query
|
||||
// description: Describes the last point that notifications were checked. Anything updated since this time will not be updated.
|
||||
// type: string
|
||||
// format: date-time
|
||||
// required: false
|
||||
// responses:
|
||||
// "205":
|
||||
// "$ref": "#/responses/NotificationThreadList"
|
||||
|
||||
lastRead := int64(0)
|
||||
qLastRead := ctx.FormTrim("last_read_at")
|
||||
if len(qLastRead) > 0 {
|
||||
tmpLastRead, err := time.Parse(time.RFC3339, qLastRead)
|
||||
if err != nil {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if !tmpLastRead.IsZero() {
|
||||
lastRead = tmpLastRead.Unix()
|
||||
}
|
||||
}
|
||||
|
||||
opts := &activities_model.FindNotificationOptions{
|
||||
UserID: ctx.Doer.ID,
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
UpdatedBeforeUnix: lastRead,
|
||||
}
|
||||
|
||||
if !ctx.FormBool("all") {
|
||||
statuses := ctx.FormStrings("status-types")
|
||||
opts.Status = statusStringsToNotificationStatuses(statuses, []string{"unread"})
|
||||
}
|
||||
nl, err := db.Find[activities_model.Notification](ctx, opts)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
targetStatus := statusStringToNotificationStatus(ctx.FormString("to-status"))
|
||||
if targetStatus == 0 {
|
||||
targetStatus = activities_model.NotificationStatusRead
|
||||
}
|
||||
|
||||
changed := make([]*structs.NotificationThread, 0, len(nl))
|
||||
|
||||
for _, n := range nl {
|
||||
notif, err := activities_model.SetNotificationStatus(ctx, n.ID, ctx.Doer, targetStatus)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
_ = notif.LoadAttributes(ctx)
|
||||
changed = append(changed, convert.ToNotificationThread(ctx, notif))
|
||||
}
|
||||
ctx.JSON(http.StatusResetContent, changed)
|
||||
}
|
||||
118
routers/api/v1/notify/threads.go
Normal file
118
routers/api/v1/notify/threads.go
Normal file
@@ -0,0 +1,118 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package notify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
activities_model "code.gitea.io/gitea/models/activities"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/convert"
|
||||
)
|
||||
|
||||
// GetThread get notification by ID
|
||||
func GetThread(ctx *context.APIContext) {
|
||||
// swagger:operation GET /notifications/threads/{id} notification notifyGetThread
|
||||
// ---
|
||||
// summary: Get notification thread by ID
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of notification thread
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/NotificationThread"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
n := getThread(ctx)
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
if err := n.LoadAttributes(ctx); err != nil && !issues_model.IsErrCommentNotExist(err) {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, convert.ToNotificationThread(ctx, n))
|
||||
}
|
||||
|
||||
// ReadThread mark notification as read by ID
|
||||
func ReadThread(ctx *context.APIContext) {
|
||||
// swagger:operation PATCH /notifications/threads/{id} notification notifyReadThread
|
||||
// ---
|
||||
// summary: Mark notification thread as read by ID
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of notification thread
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: to-status
|
||||
// in: query
|
||||
// description: Status to mark notifications as
|
||||
// type: string
|
||||
// default: read
|
||||
// required: false
|
||||
// responses:
|
||||
// "205":
|
||||
// "$ref": "#/responses/NotificationThread"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
n := getThread(ctx)
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
|
||||
targetStatus := statusStringToNotificationStatus(ctx.FormString("to-status"))
|
||||
if targetStatus == 0 {
|
||||
targetStatus = activities_model.NotificationStatusRead
|
||||
}
|
||||
|
||||
notif, err := activities_model.SetNotificationStatus(ctx, n.ID, ctx.Doer, targetStatus)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if err = notif.LoadAttributes(ctx); err != nil && !issues_model.IsErrCommentNotExist(err) {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusResetContent, convert.ToNotificationThread(ctx, notif))
|
||||
}
|
||||
|
||||
func getThread(ctx *context.APIContext) *activities_model.Notification {
|
||||
n, err := activities_model.GetNotificationByID(ctx, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if db.IsErrNotExist(err) {
|
||||
ctx.APIError(http.StatusNotFound, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if n.UserID != ctx.Doer.ID && !ctx.Doer.IsAdmin {
|
||||
ctx.APIError(http.StatusForbidden, fmt.Errorf("only user itself and admin are allowed to read/change this thread %d", n.ID))
|
||||
return nil
|
||||
}
|
||||
return n
|
||||
}
|
||||
175
routers/api/v1/notify/user.go
Normal file
175
routers/api/v1/notify/user.go
Normal file
@@ -0,0 +1,175 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package notify
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
activities_model "code.gitea.io/gitea/models/activities"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/convert"
|
||||
)
|
||||
|
||||
// ListNotifications list users's notification threads
|
||||
func ListNotifications(ctx *context.APIContext) {
|
||||
// swagger:operation GET /notifications notification notifyGetList
|
||||
// ---
|
||||
// summary: List users's notification threads
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: all
|
||||
// in: query
|
||||
// description: If true, show notifications marked as read. Default value is false
|
||||
// type: boolean
|
||||
// - name: status-types
|
||||
// in: query
|
||||
// description: "Show notifications with the provided status types. Options are: unread, read and/or pinned. Defaults to unread & pinned."
|
||||
// type: array
|
||||
// collectionFormat: multi
|
||||
// items:
|
||||
// type: string
|
||||
// - name: subject-type
|
||||
// in: query
|
||||
// description: "filter notifications by subject type"
|
||||
// type: array
|
||||
// collectionFormat: multi
|
||||
// items:
|
||||
// type: string
|
||||
// enum: [issue,pull,commit,repository]
|
||||
// - name: since
|
||||
// in: query
|
||||
// description: Only show notifications updated after the given time. This is a timestamp in RFC 3339 format
|
||||
// type: string
|
||||
// format: date-time
|
||||
// - name: before
|
||||
// in: query
|
||||
// description: Only show notifications updated before the given time. This is a timestamp in RFC 3339 format
|
||||
// type: string
|
||||
// format: date-time
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/NotificationThreadList"
|
||||
opts := getFindNotificationOptions(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
totalCount, err := db.Count[activities_model.Notification](ctx, opts)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
nl, err := db.Find[activities_model.Notification](ctx, opts)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
err = activities_model.NotificationList(nl).LoadAttributes(ctx)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetTotalCountHeader(totalCount)
|
||||
ctx.JSON(http.StatusOK, convert.ToNotifications(ctx, nl))
|
||||
}
|
||||
|
||||
// ReadNotifications mark notification threads as read, unread, or pinned
|
||||
func ReadNotifications(ctx *context.APIContext) {
|
||||
// swagger:operation PUT /notifications notification notifyReadList
|
||||
// ---
|
||||
// summary: Mark notification threads as read, pinned or unread
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: last_read_at
|
||||
// in: query
|
||||
// description: Describes the last point that notifications were checked. Anything updated since this time will not be updated.
|
||||
// type: string
|
||||
// format: date-time
|
||||
// required: false
|
||||
// - name: all
|
||||
// in: query
|
||||
// description: If true, mark all notifications on this repo. Default value is false
|
||||
// type: string
|
||||
// required: false
|
||||
// - name: status-types
|
||||
// in: query
|
||||
// description: "Mark notifications with the provided status types. Options are: unread, read and/or pinned. Defaults to unread."
|
||||
// type: array
|
||||
// collectionFormat: multi
|
||||
// items:
|
||||
// type: string
|
||||
// required: false
|
||||
// - name: to-status
|
||||
// in: query
|
||||
// description: Status to mark notifications as, Defaults to read.
|
||||
// type: string
|
||||
// required: false
|
||||
// responses:
|
||||
// "205":
|
||||
// "$ref": "#/responses/NotificationThreadList"
|
||||
|
||||
lastRead := int64(0)
|
||||
qLastRead := ctx.FormTrim("last_read_at")
|
||||
if len(qLastRead) > 0 {
|
||||
tmpLastRead, err := time.Parse(time.RFC3339, qLastRead)
|
||||
if err != nil {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if !tmpLastRead.IsZero() {
|
||||
lastRead = tmpLastRead.Unix()
|
||||
}
|
||||
}
|
||||
opts := &activities_model.FindNotificationOptions{
|
||||
UserID: ctx.Doer.ID,
|
||||
UpdatedBeforeUnix: lastRead,
|
||||
}
|
||||
if !ctx.FormBool("all") {
|
||||
statuses := ctx.FormStrings("status-types")
|
||||
opts.Status = statusStringsToNotificationStatuses(statuses, []string{"unread"})
|
||||
}
|
||||
nl, err := db.Find[activities_model.Notification](ctx, opts)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
targetStatus := statusStringToNotificationStatus(ctx.FormString("to-status"))
|
||||
if targetStatus == 0 {
|
||||
targetStatus = activities_model.NotificationStatusRead
|
||||
}
|
||||
|
||||
changed := make([]*structs.NotificationThread, 0, len(nl))
|
||||
|
||||
for _, n := range nl {
|
||||
notif, err := activities_model.SetNotificationStatus(ctx, n.ID, ctx.Doer, targetStatus)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
_ = notif.LoadAttributes(ctx)
|
||||
changed = append(changed, convert.ToNotificationThread(ctx, notif))
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusResetContent, changed)
|
||||
}
|
||||
671
routers/api/v1/org/action.go
Normal file
671
routers/api/v1/org/action.go
Normal file
@@ -0,0 +1,671 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
secret_model "code.gitea.io/gitea/models/secret"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/routers/api/v1/shared"
|
||||
"code.gitea.io/gitea/routers/api/v1/utils"
|
||||
actions_service "code.gitea.io/gitea/services/actions"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
secret_service "code.gitea.io/gitea/services/secrets"
|
||||
)
|
||||
|
||||
// ListActionsSecrets list an organization's actions secrets
|
||||
func (Action) ListActionsSecrets(ctx *context.APIContext) {
|
||||
// swagger:operation GET /orgs/{org}/actions/secrets organization orgListActionsSecrets
|
||||
// ---
|
||||
// summary: List an organization's actions secrets
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/SecretList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
opts := &secret_model.FindSecretsOptions{
|
||||
OwnerID: ctx.Org.Organization.ID,
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
}
|
||||
|
||||
secrets, count, err := db.FindAndCount[secret_model.Secret](ctx, opts)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
apiSecrets := make([]*api.Secret, len(secrets))
|
||||
for k, v := range secrets {
|
||||
apiSecrets[k] = &api.Secret{
|
||||
Name: v.Name,
|
||||
Description: v.Description,
|
||||
Created: v.CreatedUnix.AsTime(),
|
||||
}
|
||||
}
|
||||
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, apiSecrets)
|
||||
}
|
||||
|
||||
// create or update one secret of the organization
|
||||
func (Action) CreateOrUpdateSecret(ctx *context.APIContext) {
|
||||
// swagger:operation PUT /orgs/{org}/actions/secrets/{secretname} organization updateOrgSecret
|
||||
// ---
|
||||
// summary: Create or Update a secret value in an organization
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: secretname
|
||||
// in: path
|
||||
// description: name of the secret
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/CreateOrUpdateSecretOption"
|
||||
// responses:
|
||||
// "201":
|
||||
// description: response when creating a secret
|
||||
// "204":
|
||||
// description: response when updating a secret
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
opt := web.GetForm(ctx).(*api.CreateOrUpdateSecretOption)
|
||||
|
||||
_, created, err := secret_service.CreateOrUpdateSecret(ctx, ctx.Org.Organization.ID, 0, ctx.PathParam("secretname"), opt.Data, opt.Description)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
} else if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIError(http.StatusNotFound, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if created {
|
||||
ctx.Status(http.StatusCreated)
|
||||
} else {
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteSecret delete one secret of the organization
|
||||
func (Action) DeleteSecret(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /orgs/{org}/actions/secrets/{secretname} organization deleteOrgSecret
|
||||
// ---
|
||||
// summary: Delete a secret in an organization
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: secretname
|
||||
// in: path
|
||||
// description: name of the secret
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// description: delete one secret of the organization
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
err := secret_service.DeleteSecretByName(ctx, ctx.Org.Organization.ID, 0, ctx.PathParam("secretname"))
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
} else if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIError(http.StatusNotFound, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#create-a-registration-token-for-an-organization
|
||||
// GetRegistrationToken returns the token to register org runners
|
||||
func (Action) GetRegistrationToken(ctx *context.APIContext) {
|
||||
// swagger:operation GET /orgs/{org}/actions/runners/registration-token organization orgGetRunnerRegistrationToken
|
||||
// ---
|
||||
// summary: Get an organization's actions runner registration token
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/RegistrationToken"
|
||||
|
||||
shared.GetRegistrationToken(ctx, ctx.Org.Organization.ID, 0)
|
||||
}
|
||||
|
||||
// https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#create-a-registration-token-for-an-organization
|
||||
// CreateRegistrationToken returns the token to register org runners
|
||||
func (Action) CreateRegistrationToken(ctx *context.APIContext) {
|
||||
// swagger:operation POST /orgs/{org}/actions/runners/registration-token organization orgCreateRunnerRegistrationToken
|
||||
// ---
|
||||
// summary: Get an organization's actions runner registration token
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/RegistrationToken"
|
||||
|
||||
shared.GetRegistrationToken(ctx, ctx.Org.Organization.ID, 0)
|
||||
}
|
||||
|
||||
// ListVariables list org-level variables
|
||||
func (Action) ListVariables(ctx *context.APIContext) {
|
||||
// swagger:operation GET /orgs/{org}/actions/variables organization getOrgVariablesList
|
||||
// ---
|
||||
// summary: Get an org-level variables list
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/VariableList"
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
vars, count, err := db.FindAndCount[actions_model.ActionVariable](ctx, &actions_model.FindVariablesOpts{
|
||||
OwnerID: ctx.Org.Organization.ID,
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
})
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
variables := make([]*api.ActionVariable, len(vars))
|
||||
for i, v := range vars {
|
||||
variables[i] = &api.ActionVariable{
|
||||
OwnerID: v.OwnerID,
|
||||
RepoID: v.RepoID,
|
||||
Name: v.Name,
|
||||
Data: v.Data,
|
||||
Description: v.Description,
|
||||
}
|
||||
}
|
||||
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, variables)
|
||||
}
|
||||
|
||||
// GetVariable get an org-level variable
|
||||
func (Action) GetVariable(ctx *context.APIContext) {
|
||||
// swagger:operation GET /orgs/{org}/actions/variables/{variablename} organization getOrgVariable
|
||||
// ---
|
||||
// summary: Get an org-level variable
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: variablename
|
||||
// in: path
|
||||
// description: name of the variable
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/ActionVariable"
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{
|
||||
OwnerID: ctx.Org.Organization.ID,
|
||||
Name: ctx.PathParam("variablename"),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIError(http.StatusNotFound, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
variable := &api.ActionVariable{
|
||||
OwnerID: v.OwnerID,
|
||||
RepoID: v.RepoID,
|
||||
Name: v.Name,
|
||||
Data: v.Data,
|
||||
Description: v.Description,
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, variable)
|
||||
}
|
||||
|
||||
// DeleteVariable delete an org-level variable
|
||||
func (Action) DeleteVariable(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /orgs/{org}/actions/variables/{variablename} organization deleteOrgVariable
|
||||
// ---
|
||||
// summary: Delete an org-level variable
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: variablename
|
||||
// in: path
|
||||
// description: name of the variable
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/ActionVariable"
|
||||
// "201":
|
||||
// description: response when deleting a variable
|
||||
// "204":
|
||||
// description: response when deleting a variable
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
if err := actions_service.DeleteVariableByName(ctx, ctx.Org.Organization.ID, 0, ctx.PathParam("variablename")); err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
} else if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIError(http.StatusNotFound, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// CreateVariable create an org-level variable
|
||||
func (Action) CreateVariable(ctx *context.APIContext) {
|
||||
// swagger:operation POST /orgs/{org}/actions/variables/{variablename} organization createOrgVariable
|
||||
// ---
|
||||
// summary: Create an org-level variable
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: variablename
|
||||
// in: path
|
||||
// description: name of the variable
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/CreateVariableOption"
|
||||
// responses:
|
||||
// "201":
|
||||
// description: successfully created the org-level variable
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "409":
|
||||
// description: variable name already exists.
|
||||
// "500":
|
||||
// "$ref": "#/responses/error"
|
||||
|
||||
opt := web.GetForm(ctx).(*api.CreateVariableOption)
|
||||
|
||||
ownerID := ctx.Org.Organization.ID
|
||||
variableName := ctx.PathParam("variablename")
|
||||
|
||||
v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{
|
||||
OwnerID: ownerID,
|
||||
Name: variableName,
|
||||
})
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if v != nil && v.ID > 0 {
|
||||
ctx.APIError(http.StatusConflict, util.NewAlreadyExistErrorf("variable name %s already exists", variableName))
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := actions_service.CreateVariable(ctx, ownerID, 0, variableName, opt.Value, opt.Description); err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusCreated)
|
||||
}
|
||||
|
||||
// UpdateVariable update an org-level variable
|
||||
func (Action) UpdateVariable(ctx *context.APIContext) {
|
||||
// swagger:operation PUT /orgs/{org}/actions/variables/{variablename} organization updateOrgVariable
|
||||
// ---
|
||||
// summary: Update an org-level variable
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: variablename
|
||||
// in: path
|
||||
// description: name of the variable
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/UpdateVariableOption"
|
||||
// responses:
|
||||
// "201":
|
||||
// description: response when updating an org-level variable
|
||||
// "204":
|
||||
// description: response when updating an org-level variable
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
opt := web.GetForm(ctx).(*api.UpdateVariableOption)
|
||||
|
||||
v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{
|
||||
OwnerID: ctx.Org.Organization.ID,
|
||||
Name: ctx.PathParam("variablename"),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIError(http.StatusNotFound, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if opt.Name == "" {
|
||||
opt.Name = ctx.PathParam("variablename")
|
||||
}
|
||||
|
||||
v.Name = opt.Name
|
||||
v.Data = opt.Value
|
||||
v.Description = opt.Description
|
||||
|
||||
if _, err := actions_service.UpdateVariableNameData(ctx, v); err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ListRunners get org-level runners
|
||||
func (Action) ListRunners(ctx *context.APIContext) {
|
||||
// swagger:operation GET /orgs/{org}/actions/runners organization getOrgRunners
|
||||
// ---
|
||||
// summary: Get org-level runners
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/definitions/ActionRunnersResponse"
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
shared.ListRunners(ctx, ctx.Org.Organization.ID, 0)
|
||||
}
|
||||
|
||||
// GetRunner get an org-level runner
|
||||
func (Action) GetRunner(ctx *context.APIContext) {
|
||||
// swagger:operation GET /orgs/{org}/actions/runners/{runner_id} organization getOrgRunner
|
||||
// ---
|
||||
// summary: Get an org-level runner
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: runner_id
|
||||
// in: path
|
||||
// description: id of the runner
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/definitions/ActionRunner"
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
shared.GetRunner(ctx, ctx.Org.Organization.ID, 0, ctx.PathParamInt64("runner_id"))
|
||||
}
|
||||
|
||||
// DeleteRunner delete an org-level runner
|
||||
func (Action) DeleteRunner(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /orgs/{org}/actions/runners/{runner_id} organization deleteOrgRunner
|
||||
// ---
|
||||
// summary: Delete an org-level runner
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: runner_id
|
||||
// in: path
|
||||
// description: id of the runner
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// description: runner has been deleted
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
shared.DeleteRunner(ctx, ctx.Org.Organization.ID, 0, ctx.PathParamInt64("runner_id"))
|
||||
}
|
||||
|
||||
func (Action) ListWorkflowJobs(ctx *context.APIContext) {
|
||||
// swagger:operation GET /orgs/{org}/actions/jobs organization getOrgWorkflowJobs
|
||||
// ---
|
||||
// summary: Get org-level workflow jobs
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: status
|
||||
// in: query
|
||||
// description: workflow status (pending, queued, in_progress, failure, success, skipped)
|
||||
// type: string
|
||||
// required: false
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/WorkflowJobsList"
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
shared.ListJobs(ctx, ctx.Org.Organization.ID, 0, 0)
|
||||
}
|
||||
|
||||
func (Action) ListWorkflowRuns(ctx *context.APIContext) {
|
||||
// swagger:operation GET /orgs/{org}/actions/runs organization getOrgWorkflowRuns
|
||||
// ---
|
||||
// summary: Get org-level workflow runs
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: event
|
||||
// in: query
|
||||
// description: workflow event name
|
||||
// type: string
|
||||
// required: false
|
||||
// - name: branch
|
||||
// in: query
|
||||
// description: workflow branch
|
||||
// type: string
|
||||
// required: false
|
||||
// - name: status
|
||||
// in: query
|
||||
// description: workflow status (pending, queued, in_progress, failure, success, skipped)
|
||||
// type: string
|
||||
// required: false
|
||||
// - name: actor
|
||||
// in: query
|
||||
// description: triggered by user
|
||||
// type: string
|
||||
// required: false
|
||||
// - name: head_sha
|
||||
// in: query
|
||||
// description: triggering sha of the workflow run
|
||||
// type: string
|
||||
// required: false
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/WorkflowRunsList"
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
shared.ListRuns(ctx, ctx.Org.Organization.ID, 0)
|
||||
}
|
||||
|
||||
var _ actions_service.API = new(Action)
|
||||
|
||||
// Action implements actions_service.API
|
||||
type Action struct{}
|
||||
|
||||
// NewAction creates a new Action service
|
||||
func NewAction() actions_service.API {
|
||||
return Action{}
|
||||
}
|
||||
80
routers/api/v1/org/avatar.go
Normal file
80
routers/api/v1/org/avatar.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
user_service "code.gitea.io/gitea/services/user"
|
||||
)
|
||||
|
||||
// UpdateAvatarupdates the Avatar of an Organisation
|
||||
func UpdateAvatar(ctx *context.APIContext) {
|
||||
// swagger:operation POST /orgs/{org}/avatar organization orgUpdateAvatar
|
||||
// ---
|
||||
// summary: Update Avatar
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/UpdateUserAvatarOption"
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
form := web.GetForm(ctx).(*api.UpdateUserAvatarOption)
|
||||
|
||||
content, err := base64.StdEncoding.DecodeString(form.Image)
|
||||
if err != nil {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = user_service.UploadAvatar(ctx, ctx.Org.Organization.AsUser(), content)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// DeleteAvatar deletes the Avatar of an Organisation
|
||||
func DeleteAvatar(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /orgs/{org}/avatar organization orgDeleteAvatar
|
||||
// ---
|
||||
// summary: Delete Avatar
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
err := user_service.DeleteAvatar(ctx, ctx.Org.Organization.AsUser())
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
116
routers/api/v1/org/block.go
Normal file
116
routers/api/v1/org/block.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Copyright 2024 The Gitea Authors.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/routers/api/v1/shared"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
func ListBlocks(ctx *context.APIContext) {
|
||||
// swagger:operation GET /orgs/{org}/blocks organization organizationListBlocks
|
||||
// ---
|
||||
// summary: List users blocked by the organization
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// produces:
|
||||
// - application/json
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/UserList"
|
||||
|
||||
shared.ListBlocks(ctx, ctx.Org.Organization.AsUser())
|
||||
}
|
||||
|
||||
func CheckUserBlock(ctx *context.APIContext) {
|
||||
// swagger:operation GET /orgs/{org}/blocks/{username} organization organizationCheckUserBlock
|
||||
// ---
|
||||
// summary: Check if a user is blocked by the organization
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user to check
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
shared.CheckUserBlock(ctx, ctx.Org.Organization.AsUser())
|
||||
}
|
||||
|
||||
func BlockUser(ctx *context.APIContext) {
|
||||
// swagger:operation PUT /orgs/{org}/blocks/{username} organization organizationBlockUser
|
||||
// ---
|
||||
// summary: Block a user
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user to block
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: note
|
||||
// in: query
|
||||
// description: optional note for the block
|
||||
// type: string
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
shared.BlockUser(ctx, ctx.Org.Organization.AsUser())
|
||||
}
|
||||
|
||||
func UnblockUser(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /orgs/{org}/blocks/{username} organization organizationUnblockUser
|
||||
// ---
|
||||
// summary: Unblock a user
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user to unblock
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
shared.UnblockUser(ctx, ctx.Doer, ctx.Org.Organization.AsUser())
|
||||
}
|
||||
189
routers/api/v1/org/hook.go
Normal file
189
routers/api/v1/org/hook.go
Normal file
@@ -0,0 +1,189 @@
|
||||
// Copyright 2016 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/routers/api/v1/utils"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
webhook_service "code.gitea.io/gitea/services/webhook"
|
||||
)
|
||||
|
||||
// ListHooks list an organziation's webhooks
|
||||
func ListHooks(ctx *context.APIContext) {
|
||||
// swagger:operation GET /orgs/{org}/hooks organization orgListHooks
|
||||
// ---
|
||||
// summary: List an organization's webhooks
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/HookList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
utils.ListOwnerHooks(
|
||||
ctx,
|
||||
ctx.ContextUser,
|
||||
)
|
||||
}
|
||||
|
||||
// GetHook get an organization's hook by id
|
||||
func GetHook(ctx *context.APIContext) {
|
||||
// swagger:operation GET /orgs/{org}/hooks/{id} organization orgGetHook
|
||||
// ---
|
||||
// summary: Get a hook
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the hook to get
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/Hook"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
hook, err := utils.GetOwnerHook(ctx, ctx.ContextUser.ID, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
apiHook, err := webhook_service.ToHook(ctx.ContextUser.HomeLink(), hook)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, apiHook)
|
||||
}
|
||||
|
||||
// CreateHook create a hook for an organization
|
||||
func CreateHook(ctx *context.APIContext) {
|
||||
// swagger:operation POST /orgs/{org}/hooks organization orgCreateHook
|
||||
// ---
|
||||
// summary: Create a hook
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/CreateHookOption"
|
||||
// responses:
|
||||
// "201":
|
||||
// "$ref": "#/responses/Hook"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
utils.AddOwnerHook(
|
||||
ctx,
|
||||
ctx.ContextUser,
|
||||
web.GetForm(ctx).(*api.CreateHookOption),
|
||||
)
|
||||
}
|
||||
|
||||
// EditHook modify a hook of an organization
|
||||
func EditHook(ctx *context.APIContext) {
|
||||
// swagger:operation PATCH /orgs/{org}/hooks/{id} organization orgEditHook
|
||||
// ---
|
||||
// summary: Update a hook
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the hook to update
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/EditHookOption"
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/Hook"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
utils.EditOwnerHook(
|
||||
ctx,
|
||||
ctx.ContextUser,
|
||||
web.GetForm(ctx).(*api.EditHookOption),
|
||||
ctx.PathParamInt64("id"),
|
||||
)
|
||||
}
|
||||
|
||||
// DeleteHook delete a hook of an organization
|
||||
func DeleteHook(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /orgs/{org}/hooks/{id} organization orgDeleteHook
|
||||
// ---
|
||||
// summary: Delete a hook
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the hook to delete
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
utils.DeleteOwnerHook(
|
||||
ctx,
|
||||
ctx.ContextUser,
|
||||
ctx.PathParamInt64("id"),
|
||||
)
|
||||
}
|
||||
258
routers/api/v1/org/label.go
Normal file
258
routers/api/v1/org/label.go
Normal file
@@ -0,0 +1,258 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/modules/label"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/routers/api/v1/utils"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/convert"
|
||||
)
|
||||
|
||||
// ListLabels list all the labels of an organization
|
||||
func ListLabels(ctx *context.APIContext) {
|
||||
// swagger:operation GET /orgs/{org}/labels organization orgListLabels
|
||||
// ---
|
||||
// summary: List an organization's labels
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/LabelList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
labels, err := issues_model.GetLabelsByOrgID(ctx, ctx.Org.Organization.ID, ctx.FormString("sort"), utils.GetListOptions(ctx))
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
count, err := issues_model.CountLabelsByOrgID(ctx, ctx.Org.Organization.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, convert.ToLabelList(labels, nil, ctx.Org.Organization.AsUser()))
|
||||
}
|
||||
|
||||
// CreateLabel create a label for a repository
|
||||
func CreateLabel(ctx *context.APIContext) {
|
||||
// swagger:operation POST /orgs/{org}/labels organization orgCreateLabel
|
||||
// ---
|
||||
// summary: Create a label for an organization
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/CreateLabelOption"
|
||||
// responses:
|
||||
// "201":
|
||||
// "$ref": "#/responses/Label"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
form := web.GetForm(ctx).(*api.CreateLabelOption)
|
||||
form.Color = strings.Trim(form.Color, " ")
|
||||
color, err := label.NormalizeColor(form.Color)
|
||||
if err != nil {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
return
|
||||
}
|
||||
form.Color = color
|
||||
|
||||
label := &issues_model.Label{
|
||||
Name: form.Name,
|
||||
Exclusive: form.Exclusive,
|
||||
Color: form.Color,
|
||||
OrgID: ctx.Org.Organization.ID,
|
||||
Description: form.Description,
|
||||
}
|
||||
if err := issues_model.NewLabel(ctx, label); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusCreated, convert.ToLabel(label, nil, ctx.Org.Organization.AsUser()))
|
||||
}
|
||||
|
||||
// GetLabel get label by organization and label id
|
||||
func GetLabel(ctx *context.APIContext) {
|
||||
// swagger:operation GET /orgs/{org}/labels/{id} organization orgGetLabel
|
||||
// ---
|
||||
// summary: Get a single label
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the label to get
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/Label"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
var (
|
||||
label *issues_model.Label
|
||||
err error
|
||||
)
|
||||
strID := ctx.PathParam("id")
|
||||
if intID, err2 := strconv.ParseInt(strID, 10, 64); err2 != nil {
|
||||
label, err = issues_model.GetLabelInOrgByName(ctx, ctx.Org.Organization.ID, strID)
|
||||
} else {
|
||||
label, err = issues_model.GetLabelInOrgByID(ctx, ctx.Org.Organization.ID, intID)
|
||||
}
|
||||
if err != nil {
|
||||
if issues_model.IsErrOrgLabelNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, convert.ToLabel(label, nil, ctx.Org.Organization.AsUser()))
|
||||
}
|
||||
|
||||
// EditLabel modify a label for an Organization
|
||||
func EditLabel(ctx *context.APIContext) {
|
||||
// swagger:operation PATCH /orgs/{org}/labels/{id} organization orgEditLabel
|
||||
// ---
|
||||
// summary: Update a label
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the label to edit
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/EditLabelOption"
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/Label"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
form := web.GetForm(ctx).(*api.EditLabelOption)
|
||||
l, err := issues_model.GetLabelInOrgByID(ctx, ctx.Org.Organization.ID, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrOrgLabelNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if form.Name != nil {
|
||||
l.Name = *form.Name
|
||||
}
|
||||
if form.Exclusive != nil {
|
||||
l.Exclusive = *form.Exclusive
|
||||
}
|
||||
if form.Color != nil {
|
||||
color, err := label.NormalizeColor(*form.Color)
|
||||
if err != nil {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
return
|
||||
}
|
||||
l.Color = color
|
||||
}
|
||||
if form.Description != nil {
|
||||
l.Description = *form.Description
|
||||
}
|
||||
l.SetArchived(form.IsArchived != nil && *form.IsArchived)
|
||||
if err := issues_model.UpdateLabel(ctx, l); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, convert.ToLabel(l, nil, ctx.Org.Organization.AsUser()))
|
||||
}
|
||||
|
||||
// DeleteLabel delete a label for an organization
|
||||
func DeleteLabel(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /orgs/{org}/labels/{id} organization orgDeleteLabel
|
||||
// ---
|
||||
// summary: Delete a label
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the label to delete
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
if err := issues_model.DeleteLabel(ctx, ctx.Org.Organization.ID, ctx.PathParamInt64("id")); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
344
routers/api/v1/org/member.go
Normal file
344
routers/api/v1/org/member.go
Normal file
@@ -0,0 +1,344 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/routers/api/v1/user"
|
||||
"code.gitea.io/gitea/routers/api/v1/utils"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/convert"
|
||||
org_service "code.gitea.io/gitea/services/org"
|
||||
)
|
||||
|
||||
// listMembers list an organization's members
|
||||
func listMembers(ctx *context.APIContext, isMember bool) {
|
||||
opts := &organization.FindOrgMembersOpts{
|
||||
Doer: ctx.Doer,
|
||||
IsDoerMember: isMember,
|
||||
OrgID: ctx.Org.Organization.ID,
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
}
|
||||
|
||||
count, err := organization.CountOrgMembers(ctx, opts)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
members, _, err := organization.FindOrgMembers(ctx, opts)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
apiMembers := make([]*api.User, len(members))
|
||||
for i, member := range members {
|
||||
apiMembers[i] = convert.ToUser(ctx, member, ctx.Doer)
|
||||
}
|
||||
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, apiMembers)
|
||||
}
|
||||
|
||||
// ListMembers list an organization's members
|
||||
func ListMembers(ctx *context.APIContext) {
|
||||
// swagger:operation GET /orgs/{org}/members organization orgListMembers
|
||||
// ---
|
||||
// summary: List an organization's members
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/UserList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
var (
|
||||
isMember bool
|
||||
err error
|
||||
)
|
||||
|
||||
if ctx.Doer != nil {
|
||||
isMember, err = ctx.Org.Organization.IsOrgMember(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
listMembers(ctx, isMember)
|
||||
}
|
||||
|
||||
// ListPublicMembers list an organization's public members
|
||||
func ListPublicMembers(ctx *context.APIContext) {
|
||||
// swagger:operation GET /orgs/{org}/public_members organization orgListPublicMembers
|
||||
// ---
|
||||
// summary: List an organization's public members
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// produces:
|
||||
// - application/json
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/UserList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
listMembers(ctx, false)
|
||||
}
|
||||
|
||||
// IsMember check if a user is a member of an organization
|
||||
func IsMember(ctx *context.APIContext) {
|
||||
// swagger:operation GET /orgs/{org}/members/{username} organization orgIsMember
|
||||
// ---
|
||||
// summary: Check if a user is a member of an organization
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user to check for an organization membership
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// description: user is a member
|
||||
// "303":
|
||||
// description: redirection to /orgs/{org}/public_members/{username}
|
||||
// "404":
|
||||
// description: user is not a member
|
||||
|
||||
userToCheck := user.GetContextUserByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
if ctx.Doer != nil {
|
||||
userIsMember, err := ctx.Org.Organization.IsOrgMember(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
} else if userIsMember || ctx.Doer.IsAdmin {
|
||||
userToCheckIsMember, err := ctx.Org.Organization.IsOrgMember(ctx, userToCheck.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
} else if userToCheckIsMember {
|
||||
ctx.Status(http.StatusNoContent)
|
||||
} else {
|
||||
ctx.APIErrorNotFound()
|
||||
}
|
||||
return
|
||||
} else if ctx.Doer.ID == userToCheck.ID {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
redirectURL := setting.AppSubURL + "/api/v1/orgs/" + url.PathEscape(ctx.Org.Organization.Name) + "/public_members/" + url.PathEscape(userToCheck.Name)
|
||||
ctx.Redirect(redirectURL)
|
||||
}
|
||||
|
||||
// IsPublicMember check if a user is a public member of an organization
|
||||
func IsPublicMember(ctx *context.APIContext) {
|
||||
// swagger:operation GET /orgs/{org}/public_members/{username} organization orgIsPublicMember
|
||||
// ---
|
||||
// summary: Check if a user is a public member of an organization
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user to check for a public organization membership
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// description: user is a public member
|
||||
// "404":
|
||||
// description: user is not a public member
|
||||
|
||||
userToCheck := user.GetContextUserByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
is, err := organization.IsPublicMembership(ctx, ctx.Org.Organization.ID, userToCheck.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if is {
|
||||
ctx.Status(http.StatusNoContent)
|
||||
} else {
|
||||
ctx.APIErrorNotFound()
|
||||
}
|
||||
}
|
||||
|
||||
func checkCanChangeOrgUserStatus(ctx *context.APIContext, targetUser *user_model.User) {
|
||||
// allow user themselves to change their status, and allow admins to change any user
|
||||
if targetUser.ID == ctx.Doer.ID || ctx.Doer.IsAdmin {
|
||||
return
|
||||
}
|
||||
// allow org owners to change status of members
|
||||
isOwner, err := ctx.Org.Organization.IsOwnedBy(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.APIError(http.StatusInternalServerError, err)
|
||||
} else if !isOwner {
|
||||
ctx.APIError(http.StatusForbidden, "Cannot change member visibility")
|
||||
}
|
||||
}
|
||||
|
||||
// PublicizeMember make a member's membership public
|
||||
func PublicizeMember(ctx *context.APIContext) {
|
||||
// swagger:operation PUT /orgs/{org}/public_members/{username} organization orgPublicizeMember
|
||||
// ---
|
||||
// summary: Publicize a user's membership
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user whose membership is to be publicized
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// description: membership publicized
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
userToPublicize := user.GetContextUserByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
checkCanChangeOrgUserStatus(ctx, userToPublicize)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
err := organization.ChangeOrgUserStatus(ctx, ctx.Org.Organization.ID, userToPublicize.ID, true)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ConcealMember make a member's membership not public
|
||||
func ConcealMember(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /orgs/{org}/public_members/{username} organization orgConcealMember
|
||||
// ---
|
||||
// summary: Conceal a user's membership
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user whose membership is to be concealed
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
userToConceal := user.GetContextUserByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
checkCanChangeOrgUserStatus(ctx, userToConceal)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
err := organization.ChangeOrgUserStatus(ctx, ctx.Org.Organization.ID, userToConceal.ID, false)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// DeleteMember remove a member from an organization
|
||||
func DeleteMember(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /orgs/{org}/members/{username} organization orgDeleteMember
|
||||
// ---
|
||||
// summary: Remove a member from an organization
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user to remove from the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// description: member removed
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
member := user.GetContextUserByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
if err := org_service.RemoveOrgUser(ctx, ctx.Org.Organization, member); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
495
routers/api/v1/org/org.go
Normal file
495
routers/api/v1/org/org.go
Normal file
@@ -0,0 +1,495 @@
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
activities_model "code.gitea.io/gitea/models/activities"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/routers/api/v1/user"
|
||||
"code.gitea.io/gitea/routers/api/v1/utils"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/convert"
|
||||
feed_service "code.gitea.io/gitea/services/feed"
|
||||
"code.gitea.io/gitea/services/org"
|
||||
user_service "code.gitea.io/gitea/services/user"
|
||||
)
|
||||
|
||||
func listUserOrgs(ctx *context.APIContext, u *user_model.User) {
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
opts := organization.FindOrgOptions{
|
||||
ListOptions: listOptions,
|
||||
UserID: u.ID,
|
||||
IncludeVisibility: organization.DoerViewOtherVisibility(ctx.Doer, u),
|
||||
}
|
||||
orgs, maxResults, err := db.FindAndCount[organization.Organization](ctx, opts)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
apiOrgs := make([]*api.Organization, len(orgs))
|
||||
for i := range orgs {
|
||||
apiOrgs[i] = convert.ToOrganization(ctx, orgs[i])
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(maxResults), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(maxResults)
|
||||
ctx.JSON(http.StatusOK, &apiOrgs)
|
||||
}
|
||||
|
||||
// ListMyOrgs list all my orgs
|
||||
func ListMyOrgs(ctx *context.APIContext) {
|
||||
// swagger:operation GET /user/orgs organization orgListCurrentUserOrgs
|
||||
// ---
|
||||
// summary: List the current user's organizations
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/OrganizationList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
listUserOrgs(ctx, ctx.Doer)
|
||||
}
|
||||
|
||||
// ListUserOrgs list user's orgs
|
||||
func ListUserOrgs(ctx *context.APIContext) {
|
||||
// swagger:operation GET /users/{username}/orgs organization orgListUserOrgs
|
||||
// ---
|
||||
// summary: List a user's organizations
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user whose organizations are to be listed
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/OrganizationList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
listUserOrgs(ctx, ctx.ContextUser)
|
||||
}
|
||||
|
||||
// GetUserOrgsPermissions get user permissions in organization
|
||||
func GetUserOrgsPermissions(ctx *context.APIContext) {
|
||||
// swagger:operation GET /users/{username}/orgs/{org}/permissions organization orgGetUserPermissions
|
||||
// ---
|
||||
// summary: Get user permissions in organization
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user whose permissions are to be obtained
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/OrganizationPermissions"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
var o *user_model.User
|
||||
if o = user.GetUserByPathParam(ctx, "org"); o == nil {
|
||||
return
|
||||
}
|
||||
|
||||
op := api.OrganizationPermissions{}
|
||||
|
||||
if !organization.HasOrgOrUserVisible(ctx, o, ctx.ContextUser) {
|
||||
ctx.APIErrorNotFound("HasOrgOrUserVisible", nil)
|
||||
return
|
||||
}
|
||||
|
||||
org := organization.OrgFromUser(o)
|
||||
authorizeLevel, err := org.GetOrgUserMaxAuthorizeLevel(ctx, ctx.ContextUser.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
if authorizeLevel > perm.AccessModeNone {
|
||||
op.CanRead = true
|
||||
}
|
||||
if authorizeLevel > perm.AccessModeRead {
|
||||
op.CanWrite = true
|
||||
}
|
||||
if authorizeLevel > perm.AccessModeWrite {
|
||||
op.IsAdmin = true
|
||||
}
|
||||
if authorizeLevel > perm.AccessModeAdmin {
|
||||
op.IsOwner = true
|
||||
}
|
||||
|
||||
op.CanCreateRepository, err = org.CanCreateOrgRepo(ctx, ctx.ContextUser.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, op)
|
||||
}
|
||||
|
||||
// GetAll return list of all public organizations
|
||||
func GetAll(ctx *context.APIContext) {
|
||||
// swagger:operation Get /orgs organization orgGetAll
|
||||
// ---
|
||||
// summary: Get list of organizations
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/OrganizationList"
|
||||
|
||||
vMode := []api.VisibleType{api.VisibleTypePublic}
|
||||
if ctx.IsSigned && !ctx.PublicOnly {
|
||||
vMode = append(vMode, api.VisibleTypeLimited)
|
||||
if ctx.Doer.IsAdmin {
|
||||
vMode = append(vMode, api.VisibleTypePrivate)
|
||||
}
|
||||
}
|
||||
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
|
||||
publicOrgs, maxResults, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{
|
||||
Actor: ctx.Doer,
|
||||
ListOptions: listOptions,
|
||||
Type: user_model.UserTypeOrganization,
|
||||
OrderBy: db.SearchOrderByAlphabetically,
|
||||
Visible: vMode,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
orgs := make([]*api.Organization, len(publicOrgs))
|
||||
for i := range publicOrgs {
|
||||
orgs[i] = convert.ToOrganization(ctx, organization.OrgFromUser(publicOrgs[i]))
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(maxResults), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(maxResults)
|
||||
ctx.JSON(http.StatusOK, &orgs)
|
||||
}
|
||||
|
||||
// Create api for create organization
|
||||
func Create(ctx *context.APIContext) {
|
||||
// swagger:operation POST /orgs organization orgCreate
|
||||
// ---
|
||||
// summary: Create an organization
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: organization
|
||||
// in: body
|
||||
// required: true
|
||||
// schema: { "$ref": "#/definitions/CreateOrgOption" }
|
||||
// responses:
|
||||
// "201":
|
||||
// "$ref": "#/responses/Organization"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
form := web.GetForm(ctx).(*api.CreateOrgOption)
|
||||
if !ctx.Doer.CanCreateOrganization() {
|
||||
ctx.APIError(http.StatusForbidden, nil)
|
||||
return
|
||||
}
|
||||
|
||||
visibility := api.VisibleTypePublic
|
||||
if form.Visibility != "" {
|
||||
visibility = api.VisibilityModes[form.Visibility]
|
||||
}
|
||||
|
||||
org := &organization.Organization{
|
||||
Name: form.UserName,
|
||||
FullName: form.FullName,
|
||||
Email: form.Email,
|
||||
Description: form.Description,
|
||||
Website: form.Website,
|
||||
Location: form.Location,
|
||||
IsActive: true,
|
||||
Type: user_model.UserTypeOrganization,
|
||||
Visibility: visibility,
|
||||
RepoAdminChangeTeamAccess: form.RepoAdminChangeTeamAccess,
|
||||
}
|
||||
if err := organization.CreateOrganization(ctx, org, ctx.Doer); err != nil {
|
||||
if user_model.IsErrUserAlreadyExist(err) ||
|
||||
db.IsErrNameReserved(err) ||
|
||||
db.IsErrNameCharsNotAllowed(err) ||
|
||||
db.IsErrNamePatternNotAllowed(err) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusCreated, convert.ToOrganization(ctx, org))
|
||||
}
|
||||
|
||||
// Get get an organization
|
||||
func Get(ctx *context.APIContext) {
|
||||
// swagger:operation GET /orgs/{org} organization orgGet
|
||||
// ---
|
||||
// summary: Get an organization
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization to get
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/Organization"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
if !organization.HasOrgOrUserVisible(ctx, ctx.Org.Organization.AsUser(), ctx.Doer) {
|
||||
ctx.APIErrorNotFound("HasOrgOrUserVisible", nil)
|
||||
return
|
||||
}
|
||||
|
||||
org := convert.ToOrganization(ctx, ctx.Org.Organization)
|
||||
|
||||
// Don't show Mail, when User is not logged in
|
||||
if ctx.Doer == nil {
|
||||
org.Email = ""
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, org)
|
||||
}
|
||||
|
||||
func Rename(ctx *context.APIContext) {
|
||||
// swagger:operation POST /orgs/{org}/rename organization renameOrg
|
||||
// ---
|
||||
// summary: Rename an organization
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: existing org name
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/RenameOrgOption"
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
form := web.GetForm(ctx).(*api.RenameOrgOption)
|
||||
orgUser := ctx.Org.Organization.AsUser()
|
||||
if err := user_service.RenameUser(ctx, orgUser, form.NewName); err != nil {
|
||||
if user_model.IsErrUserAlreadyExist(err) || db.IsErrNameReserved(err) || db.IsErrNamePatternNotAllowed(err) || db.IsErrNameCharsNotAllowed(err) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Edit change an organization's information
|
||||
func Edit(ctx *context.APIContext) {
|
||||
// swagger:operation PATCH /orgs/{org} organization orgEdit
|
||||
// ---
|
||||
// summary: Edit an organization
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization to edit
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/EditOrgOption"
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/Organization"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
form := web.GetForm(ctx).(*api.EditOrgOption)
|
||||
|
||||
if form.Email != "" {
|
||||
if err := user_service.ReplacePrimaryEmailAddress(ctx, ctx.Org.Organization.AsUser(), form.Email); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
opts := &user_service.UpdateOptions{
|
||||
FullName: optional.Some(form.FullName),
|
||||
Description: optional.Some(form.Description),
|
||||
Website: optional.Some(form.Website),
|
||||
Location: optional.Some(form.Location),
|
||||
Visibility: optional.FromMapLookup(api.VisibilityModes, form.Visibility),
|
||||
RepoAdminChangeTeamAccess: optional.FromPtr(form.RepoAdminChangeTeamAccess),
|
||||
}
|
||||
if err := user_service.UpdateUser(ctx, ctx.Org.Organization.AsUser(), opts); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, convert.ToOrganization(ctx, ctx.Org.Organization))
|
||||
}
|
||||
|
||||
// Delete an organization
|
||||
func Delete(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /orgs/{org} organization orgDelete
|
||||
// ---
|
||||
// summary: Delete an organization
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: organization that is to be deleted
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
if err := org.DeleteOrganization(ctx, ctx.Org.Organization, false); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func ListOrgActivityFeeds(ctx *context.APIContext) {
|
||||
// swagger:operation GET /orgs/{org}/activities/feeds organization orgListActivityFeeds
|
||||
// ---
|
||||
// summary: List an organization's activity feeds
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the org
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: date
|
||||
// in: query
|
||||
// description: the date of the activities to be found
|
||||
// type: string
|
||||
// format: date
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/ActivityFeedsList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
includePrivate := false
|
||||
if ctx.IsSigned {
|
||||
if ctx.Doer.IsAdmin {
|
||||
includePrivate = true
|
||||
} else {
|
||||
org := organization.OrgFromUser(ctx.ContextUser)
|
||||
isMember, err := org.IsOrgMember(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
includePrivate = isMember
|
||||
}
|
||||
}
|
||||
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
|
||||
opts := activities_model.GetFeedsOptions{
|
||||
RequestedUser: ctx.ContextUser,
|
||||
Actor: ctx.Doer,
|
||||
IncludePrivate: includePrivate,
|
||||
Date: ctx.FormString("date"),
|
||||
ListOptions: listOptions,
|
||||
}
|
||||
|
||||
feeds, count, err := feed_service.GetFeeds(ctx, opts)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.SetTotalCountHeader(count)
|
||||
|
||||
ctx.JSON(http.StatusOK, convert.ToActivities(ctx, feeds, ctx.Doer))
|
||||
}
|
||||
880
routers/api/v1/org/team.go
Normal file
880
routers/api/v1/org/team.go
Normal file
@@ -0,0 +1,880 @@
|
||||
// Copyright 2016 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
activities_model "code.gitea.io/gitea/models/activities"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
access_model "code.gitea.io/gitea/models/perm/access"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
unit_model "code.gitea.io/gitea/models/unit"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/routers/api/v1/user"
|
||||
"code.gitea.io/gitea/routers/api/v1/utils"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/convert"
|
||||
feed_service "code.gitea.io/gitea/services/feed"
|
||||
org_service "code.gitea.io/gitea/services/org"
|
||||
repo_service "code.gitea.io/gitea/services/repository"
|
||||
)
|
||||
|
||||
// ListTeams list all the teams of an organization
|
||||
func ListTeams(ctx *context.APIContext) {
|
||||
// swagger:operation GET /orgs/{org}/teams organization orgListTeams
|
||||
// ---
|
||||
// summary: List an organization's teams
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/TeamList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
teams, count, err := organization.SearchTeam(ctx, &organization.SearchTeamOptions{
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
OrgID: ctx.Org.Organization.ID,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
apiTeams, err := convert.ToTeams(ctx, teams, false)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, apiTeams)
|
||||
}
|
||||
|
||||
// ListUserTeams list all the teams a user belongs to
|
||||
func ListUserTeams(ctx *context.APIContext) {
|
||||
// swagger:operation GET /user/teams user userListTeams
|
||||
// ---
|
||||
// summary: List all the teams a user belongs to
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/TeamList"
|
||||
|
||||
teams, count, err := organization.SearchTeam(ctx, &organization.SearchTeamOptions{
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
UserID: ctx.Doer.ID,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
apiTeams, err := convert.ToTeams(ctx, teams, true)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, apiTeams)
|
||||
}
|
||||
|
||||
// GetTeam api for get a team
|
||||
func GetTeam(ctx *context.APIContext) {
|
||||
// swagger:operation GET /teams/{id} organization orgGetTeam
|
||||
// ---
|
||||
// summary: Get a team
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the team to get
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/Team"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
apiTeam, err := convert.ToTeam(ctx, ctx.Org.Team, true)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, apiTeam)
|
||||
}
|
||||
|
||||
func attachTeamUnits(team *organization.Team, defaultAccessMode perm.AccessMode, units []string) {
|
||||
unitTypes, _ := unit_model.FindUnitTypes(units...)
|
||||
team.Units = make([]*organization.TeamUnit, 0, len(units))
|
||||
for _, tp := range unitTypes {
|
||||
team.Units = append(team.Units, &organization.TeamUnit{
|
||||
OrgID: team.OrgID,
|
||||
Type: tp,
|
||||
AccessMode: defaultAccessMode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func attachTeamUnitsMap(team *organization.Team, unitsMap map[string]string) {
|
||||
team.Units = make([]*organization.TeamUnit, 0, len(unitsMap))
|
||||
for unitKey, p := range unitsMap {
|
||||
team.Units = append(team.Units, &organization.TeamUnit{
|
||||
OrgID: team.OrgID,
|
||||
Type: unit_model.TypeFromKey(unitKey),
|
||||
AccessMode: perm.ParseAccessMode(p),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func attachAdminTeamUnits(team *organization.Team) {
|
||||
team.Units = make([]*organization.TeamUnit, 0, len(unit_model.AllRepoUnitTypes))
|
||||
for _, ut := range unit_model.AllRepoUnitTypes {
|
||||
up := perm.AccessModeAdmin
|
||||
if ut == unit_model.TypeExternalTracker || ut == unit_model.TypeExternalWiki {
|
||||
up = perm.AccessModeRead
|
||||
}
|
||||
team.Units = append(team.Units, &organization.TeamUnit{
|
||||
OrgID: team.OrgID,
|
||||
Type: ut,
|
||||
AccessMode: up,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CreateTeam api for create a team
|
||||
func CreateTeam(ctx *context.APIContext) {
|
||||
// swagger:operation POST /orgs/{org}/teams organization orgCreateTeam
|
||||
// ---
|
||||
// summary: Create a team
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/CreateTeamOption"
|
||||
// responses:
|
||||
// "201":
|
||||
// "$ref": "#/responses/Team"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
form := web.GetForm(ctx).(*api.CreateTeamOption)
|
||||
teamPermission := perm.ParseAccessMode(form.Permission, perm.AccessModeNone, perm.AccessModeAdmin)
|
||||
team := &organization.Team{
|
||||
OrgID: ctx.Org.Organization.ID,
|
||||
Name: form.Name,
|
||||
Description: form.Description,
|
||||
IncludesAllRepositories: form.IncludesAllRepositories,
|
||||
CanCreateOrgRepo: form.CanCreateOrgRepo,
|
||||
AccessMode: teamPermission,
|
||||
}
|
||||
|
||||
if team.AccessMode < perm.AccessModeAdmin {
|
||||
if len(form.UnitsMap) > 0 {
|
||||
attachTeamUnitsMap(team, form.UnitsMap)
|
||||
} else if len(form.Units) > 0 {
|
||||
unitPerm := perm.ParseAccessMode(form.Permission, perm.AccessModeRead, perm.AccessModeWrite)
|
||||
attachTeamUnits(team, unitPerm, form.Units)
|
||||
} else {
|
||||
ctx.APIErrorInternal(errors.New("units permission should not be empty"))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
attachAdminTeamUnits(team)
|
||||
}
|
||||
|
||||
if err := org_service.NewTeam(ctx, team); err != nil {
|
||||
if organization.IsErrTeamAlreadyExist(err) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
apiTeam, err := convert.ToTeam(ctx, team, true)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusCreated, apiTeam)
|
||||
}
|
||||
|
||||
// EditTeam api for edit a team
|
||||
func EditTeam(ctx *context.APIContext) {
|
||||
// swagger:operation PATCH /teams/{id} organization orgEditTeam
|
||||
// ---
|
||||
// summary: Edit a team
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the team to edit
|
||||
// type: integer
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/EditTeamOption"
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/Team"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
form := web.GetForm(ctx).(*api.EditTeamOption)
|
||||
team := ctx.Org.Team
|
||||
if err := team.LoadUnits(ctx); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
if form.CanCreateOrgRepo != nil {
|
||||
team.CanCreateOrgRepo = team.IsOwnerTeam() || *form.CanCreateOrgRepo
|
||||
}
|
||||
|
||||
if len(form.Name) > 0 {
|
||||
team.Name = form.Name
|
||||
}
|
||||
|
||||
if form.Description != nil {
|
||||
team.Description = *form.Description
|
||||
}
|
||||
|
||||
isAuthChanged := false
|
||||
isIncludeAllChanged := false
|
||||
if !team.IsOwnerTeam() && len(form.Permission) != 0 {
|
||||
teamPermission := perm.ParseAccessMode(form.Permission, perm.AccessModeNone, perm.AccessModeAdmin)
|
||||
if team.AccessMode != teamPermission {
|
||||
isAuthChanged = true
|
||||
team.AccessMode = teamPermission
|
||||
}
|
||||
|
||||
if form.IncludesAllRepositories != nil {
|
||||
isIncludeAllChanged = true
|
||||
team.IncludesAllRepositories = *form.IncludesAllRepositories
|
||||
}
|
||||
}
|
||||
|
||||
if team.AccessMode < perm.AccessModeAdmin {
|
||||
if len(form.UnitsMap) > 0 {
|
||||
attachTeamUnitsMap(team, form.UnitsMap)
|
||||
} else if len(form.Units) > 0 {
|
||||
unitPerm := perm.ParseAccessMode(form.Permission, perm.AccessModeRead, perm.AccessModeWrite)
|
||||
attachTeamUnits(team, unitPerm, form.Units)
|
||||
}
|
||||
} else {
|
||||
attachAdminTeamUnits(team)
|
||||
}
|
||||
|
||||
if err := org_service.UpdateTeam(ctx, team, isAuthChanged, isIncludeAllChanged); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
apiTeam, err := convert.ToTeam(ctx, team)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, apiTeam)
|
||||
}
|
||||
|
||||
// DeleteTeam api for delete a team
|
||||
func DeleteTeam(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /teams/{id} organization orgDeleteTeam
|
||||
// ---
|
||||
// summary: Delete a team
|
||||
// parameters:
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the team to delete
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// description: team deleted
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
if err := org_service.DeleteTeam(ctx, ctx.Org.Team); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetTeamMembers api for get a team's members
|
||||
func GetTeamMembers(ctx *context.APIContext) {
|
||||
// swagger:operation GET /teams/{id}/members organization orgListTeamMembers
|
||||
// ---
|
||||
// summary: List a team's members
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the team
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/UserList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
isMember, err := organization.IsOrganizationMember(ctx, ctx.Org.Team.OrgID, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
} else if !isMember && !ctx.Doer.IsAdmin {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
teamMembers, err := organization.GetTeamMembers(ctx, &organization.SearchMembersOptions{
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
TeamID: ctx.Org.Team.ID,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
members := make([]*api.User, len(teamMembers))
|
||||
for i, member := range teamMembers {
|
||||
members[i] = convert.ToUser(ctx, member, ctx.Doer)
|
||||
}
|
||||
|
||||
ctx.SetTotalCountHeader(int64(ctx.Org.Team.NumMembers))
|
||||
ctx.JSON(http.StatusOK, members)
|
||||
}
|
||||
|
||||
// GetTeamMember api for get a particular member of team
|
||||
func GetTeamMember(ctx *context.APIContext) {
|
||||
// swagger:operation GET /teams/{id}/members/{username} organization orgListTeamMember
|
||||
// ---
|
||||
// summary: List a particular member of team
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the team
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user whose data is to be listed
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/User"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
u := user.GetContextUserByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
teamID := ctx.PathParamInt64("teamid")
|
||||
isTeamMember, err := organization.IsUserInTeams(ctx, u.ID, []int64{teamID})
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
} else if !isTeamMember {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, convert.ToUser(ctx, u, ctx.Doer))
|
||||
}
|
||||
|
||||
// AddTeamMember api for add a member to a team
|
||||
func AddTeamMember(ctx *context.APIContext) {
|
||||
// swagger:operation PUT /teams/{id}/members/{username} organization orgAddTeamMember
|
||||
// ---
|
||||
// summary: Add a team member
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the team
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user to add to a team
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
u := user.GetContextUserByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
if err := org_service.AddTeamMember(ctx, ctx.Org.Team, u); err != nil {
|
||||
if errors.Is(err, user_model.ErrBlockedUser) {
|
||||
ctx.APIError(http.StatusForbidden, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// RemoveTeamMember api for remove one member from a team
|
||||
func RemoveTeamMember(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /teams/{id}/members/{username} organization orgRemoveTeamMember
|
||||
// ---
|
||||
// summary: Remove a team member
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the team
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user to remove from a team
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
u := user.GetContextUserByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
if err := org_service.RemoveTeamMember(ctx, ctx.Org.Team, u); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetTeamRepos api for get a team's repos
|
||||
func GetTeamRepos(ctx *context.APIContext) {
|
||||
// swagger:operation GET /teams/{id}/repos organization orgListTeamRepos
|
||||
// ---
|
||||
// summary: List a team's repos
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the team
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/RepositoryList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
team := ctx.Org.Team
|
||||
teamRepos, err := repo_model.GetTeamRepositories(ctx, &repo_model.SearchTeamRepoOptions{
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
TeamID: team.ID,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
repos := make([]*api.Repository, len(teamRepos))
|
||||
for i, repo := range teamRepos {
|
||||
permission, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
repos[i] = convert.ToRepo(ctx, repo, permission)
|
||||
}
|
||||
ctx.SetTotalCountHeader(int64(team.NumRepos))
|
||||
ctx.JSON(http.StatusOK, repos)
|
||||
}
|
||||
|
||||
// GetTeamRepo api for get a particular repo of team
|
||||
func GetTeamRepo(ctx *context.APIContext) {
|
||||
// swagger:operation GET /teams/{id}/repos/{org}/{repo} organization orgListTeamRepo
|
||||
// ---
|
||||
// summary: List a particular repo of team
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the team
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: organization that owns the repo to list
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo to list
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/Repository"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
repo := getRepositoryByParams(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
if !organization.HasTeamRepo(ctx, ctx.Org.Team.OrgID, ctx.Org.Team.ID, repo.ID) {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
permission, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, convert.ToRepo(ctx, repo, permission))
|
||||
}
|
||||
|
||||
// getRepositoryByParams get repository by a team's organization ID and repo name
|
||||
func getRepositoryByParams(ctx *context.APIContext) *repo_model.Repository {
|
||||
repo, err := repo_model.GetRepositoryByName(ctx, ctx.Org.Team.OrgID, ctx.PathParam("reponame"))
|
||||
if err != nil {
|
||||
if repo_model.IsErrRepoNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
// AddTeamRepository api for adding a repository to a team
|
||||
func AddTeamRepository(ctx *context.APIContext) {
|
||||
// swagger:operation PUT /teams/{id}/repos/{org}/{repo} organization orgAddTeamRepository
|
||||
// ---
|
||||
// summary: Add a repository to a team
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the team
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: organization that owns the repo to add
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo to add
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
repo := getRepositoryByParams(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
if access, err := access_model.AccessLevel(ctx, ctx.Doer, repo); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
} else if access < perm.AccessModeAdmin {
|
||||
ctx.APIError(http.StatusForbidden, "Must have admin-level access to the repository")
|
||||
return
|
||||
}
|
||||
if err := repo_service.TeamAddRepository(ctx, ctx.Org.Team, repo); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// RemoveTeamRepository api for removing a repository from a team
|
||||
func RemoveTeamRepository(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /teams/{id}/repos/{org}/{repo} organization orgRemoveTeamRepository
|
||||
// ---
|
||||
// summary: Remove a repository from a team
|
||||
// description: This does not delete the repository, it only removes the
|
||||
// repository from the team.
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the team
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: organization that owns the repo to remove
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo to remove
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
repo := getRepositoryByParams(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
if access, err := access_model.AccessLevel(ctx, ctx.Doer, repo); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
} else if access < perm.AccessModeAdmin {
|
||||
ctx.APIError(http.StatusForbidden, "Must have admin-level access to the repository")
|
||||
return
|
||||
}
|
||||
if err := repo_service.RemoveRepositoryFromTeam(ctx, ctx.Org.Team, repo.ID); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// SearchTeam api for searching teams
|
||||
func SearchTeam(ctx *context.APIContext) {
|
||||
// swagger:operation GET /orgs/{org}/teams/search organization teamSearch
|
||||
// ---
|
||||
// summary: Search for teams within an organization
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: q
|
||||
// in: query
|
||||
// description: keywords to search
|
||||
// type: string
|
||||
// - name: include_desc
|
||||
// in: query
|
||||
// description: include search within team description (defaults to true)
|
||||
// type: boolean
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// description: "SearchResults of a successful search"
|
||||
// schema:
|
||||
// type: object
|
||||
// properties:
|
||||
// ok:
|
||||
// type: boolean
|
||||
// data:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/Team"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
|
||||
opts := &organization.SearchTeamOptions{
|
||||
Keyword: ctx.FormTrim("q"),
|
||||
OrgID: ctx.Org.Organization.ID,
|
||||
IncludeDesc: ctx.FormString("include_desc") == "" || ctx.FormBool("include_desc"),
|
||||
ListOptions: listOptions,
|
||||
}
|
||||
|
||||
// Only admin is allowd to search for all teams
|
||||
if !ctx.Doer.IsAdmin {
|
||||
opts.UserID = ctx.Doer.ID
|
||||
}
|
||||
|
||||
teams, maxResults, err := organization.SearchTeam(ctx, opts)
|
||||
if err != nil {
|
||||
log.Error("SearchTeam failed: %v", err)
|
||||
ctx.JSON(http.StatusInternalServerError, map[string]any{
|
||||
"ok": false,
|
||||
"error": "SearchTeam internal failure",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
apiTeams, err := convert.ToTeams(ctx, teams, false)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(maxResults), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(maxResults)
|
||||
ctx.JSON(http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"data": apiTeams,
|
||||
})
|
||||
}
|
||||
|
||||
func ListTeamActivityFeeds(ctx *context.APIContext) {
|
||||
// swagger:operation GET /teams/{id}/activities/feeds organization orgListTeamActivityFeeds
|
||||
// ---
|
||||
// summary: List a team's activity feeds
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the team
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// - name: date
|
||||
// in: query
|
||||
// description: the date of the activities to be found
|
||||
// type: string
|
||||
// format: date
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/ActivityFeedsList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
|
||||
opts := activities_model.GetFeedsOptions{
|
||||
RequestedTeam: ctx.Org.Team,
|
||||
Actor: ctx.Doer,
|
||||
IncludePrivate: true,
|
||||
Date: ctx.FormString("date"),
|
||||
ListOptions: listOptions,
|
||||
}
|
||||
|
||||
feeds, count, err := feed_service.GetFeeds(ctx, opts)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.SetTotalCountHeader(count)
|
||||
|
||||
ctx.JSON(http.StatusOK, convert.ToActivities(ctx, feeds, ctx.Doer))
|
||||
}
|
||||
456
routers/api/v1/packages/package.go
Normal file
456
routers/api/v1/packages/package.go
Normal file
@@ -0,0 +1,456 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package packages
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/models/packages"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/routers/api/v1/utils"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/convert"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
)
|
||||
|
||||
// ListPackages gets all packages of an owner
|
||||
func ListPackages(ctx *context.APIContext) {
|
||||
// swagger:operation GET /packages/{owner} package listPackages
|
||||
// ---
|
||||
// summary: Gets all packages of an owner
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the packages
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// - name: type
|
||||
// in: query
|
||||
// description: package type filter
|
||||
// type: string
|
||||
// enum: [alpine, cargo, chef, composer, conan, conda, container, cran, debian, generic, go, helm, maven, npm, nuget, pub, pypi, rpm, rubygems, swift, vagrant]
|
||||
// - name: q
|
||||
// in: query
|
||||
// description: name filter
|
||||
// type: string
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/PackageList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
|
||||
apiPackages, count, err := searchPackages(ctx, &packages.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Type: packages.Type(ctx.FormTrim("type")),
|
||||
Name: packages.SearchValue{Value: ctx.FormTrim("q")},
|
||||
IsInternal: optional.Some(false),
|
||||
Paginator: &listOptions,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(count), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, apiPackages)
|
||||
}
|
||||
|
||||
// GetPackage gets a package
|
||||
func GetPackage(ctx *context.APIContext) {
|
||||
// swagger:operation GET /packages/{owner}/{type}/{name}/{version} package getPackage
|
||||
// ---
|
||||
// summary: Gets a package
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the package
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: type
|
||||
// in: path
|
||||
// description: type of the package
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: name
|
||||
// in: path
|
||||
// description: name of the package
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: version
|
||||
// in: path
|
||||
// description: version of the package
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/Package"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
apiPackage, err := convert.ToPackage(ctx, ctx.Package.Descriptor, ctx.Doer)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, apiPackage)
|
||||
}
|
||||
|
||||
// DeletePackage deletes a package
|
||||
func DeletePackage(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /packages/{owner}/{type}/{name}/{version} package deletePackage
|
||||
// ---
|
||||
// summary: Delete a package
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the package
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: type
|
||||
// in: path
|
||||
// description: type of the package
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: name
|
||||
// in: path
|
||||
// description: name of the package
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: version
|
||||
// in: path
|
||||
// description: version of the package
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
err := packages_service.RemovePackageVersion(ctx, ctx.Doer, ctx.Package.Descriptor.Version)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ListPackageFiles gets all files of a package
|
||||
func ListPackageFiles(ctx *context.APIContext) {
|
||||
// swagger:operation GET /packages/{owner}/{type}/{name}/{version}/files package listPackageFiles
|
||||
// ---
|
||||
// summary: Gets all files of a package
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the package
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: type
|
||||
// in: path
|
||||
// description: type of the package
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: name
|
||||
// in: path
|
||||
// description: name of the package
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: version
|
||||
// in: path
|
||||
// description: version of the package
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/PackageFileList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
apiPackageFiles := make([]*api.PackageFile, 0, len(ctx.Package.Descriptor.Files))
|
||||
for _, pfd := range ctx.Package.Descriptor.Files {
|
||||
apiPackageFiles = append(apiPackageFiles, convert.ToPackageFile(pfd))
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, apiPackageFiles)
|
||||
}
|
||||
|
||||
// ListPackageVersions gets all versions of a package
|
||||
func ListPackageVersions(ctx *context.APIContext) {
|
||||
// swagger:operation GET /packages/{owner}/{type}/{name} package listPackageVersions
|
||||
// ---
|
||||
// summary: Gets all versions of a package
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the package
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: type
|
||||
// in: path
|
||||
// description: type of the package
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: name
|
||||
// in: path
|
||||
// description: name of the package
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/PackageList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
|
||||
apiPackages, count, err := searchPackages(ctx, &packages.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Type: packages.Type(ctx.PathParam("type")),
|
||||
Name: packages.SearchValue{Value: ctx.PathParam("name"), ExactMatch: true},
|
||||
IsInternal: optional.Some(false),
|
||||
Paginator: &listOptions,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(count), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, apiPackages)
|
||||
}
|
||||
|
||||
// GetLatestPackageVersion gets the latest version of a package
|
||||
func GetLatestPackageVersion(ctx *context.APIContext) {
|
||||
// swagger:operation GET /packages/{owner}/{type}/{name}/-/latest package getLatestPackageVersion
|
||||
// ---
|
||||
// summary: Gets the latest version of a package
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the package
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: type
|
||||
// in: path
|
||||
// description: type of the package
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: name
|
||||
// in: path
|
||||
// description: name of the package
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/Package"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
pvs, _, err := packages.SearchLatestVersions(ctx, &packages.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Type: packages.Type(ctx.PathParam("type")),
|
||||
Name: packages.SearchValue{Value: ctx.PathParam("name"), ExactMatch: true},
|
||||
IsInternal: optional.Some(false),
|
||||
})
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if len(pvs) == 0 {
|
||||
ctx.APIError(http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
|
||||
pd, err := packages.GetPackageDescriptor(ctx, pvs[0])
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
apiPackage, err := convert.ToPackage(ctx, pd, ctx.Doer)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, apiPackage)
|
||||
}
|
||||
|
||||
// LinkPackage sets a repository link for a package
|
||||
func LinkPackage(ctx *context.APIContext) {
|
||||
// swagger:operation POST /packages/{owner}/{type}/{name}/-/link/{repo_name} package linkPackage
|
||||
// ---
|
||||
// summary: Link a package to a repository
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the package
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: type
|
||||
// in: path
|
||||
// description: type of the package
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: name
|
||||
// in: path
|
||||
// description: name of the package
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo_name
|
||||
// in: path
|
||||
// description: name of the repository to link.
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "201":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
pkg, err := packages.GetPackageByName(ctx, ctx.ContextUser.ID, packages.Type(ctx.PathParam("type")), ctx.PathParam("name"))
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIError(http.StatusNotFound, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
repo, err := repo_model.GetRepositoryByName(ctx, ctx.ContextUser.ID, ctx.PathParam("repo_name"))
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIError(http.StatusNotFound, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
err = packages_service.LinkToRepository(ctx, pkg, repo, ctx.Doer)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, util.ErrInvalidArgument):
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
case errors.Is(err, util.ErrPermissionDenied):
|
||||
ctx.APIError(http.StatusForbidden, err)
|
||||
default:
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusCreated)
|
||||
}
|
||||
|
||||
// UnlinkPackage sets a repository link for a package
|
||||
func UnlinkPackage(ctx *context.APIContext) {
|
||||
// swagger:operation POST /packages/{owner}/{type}/{name}/-/unlink package unlinkPackage
|
||||
// ---
|
||||
// summary: Unlink a package from a repository
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the package
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: type
|
||||
// in: path
|
||||
// description: type of the package
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: name
|
||||
// in: path
|
||||
// description: name of the package
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "201":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
pkg, err := packages.GetPackageByName(ctx, ctx.ContextUser.ID, packages.Type(ctx.PathParam("type")), ctx.PathParam("name"))
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIError(http.StatusNotFound, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
err = packages_service.UnlinkFromRepository(ctx, pkg, ctx.Doer)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, util.ErrPermissionDenied):
|
||||
ctx.APIError(http.StatusForbidden, err)
|
||||
case errors.Is(err, util.ErrInvalidArgument):
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
default:
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func searchPackages(ctx *context.APIContext, opts *packages.PackageSearchOptions) ([]*api.Package, int64, error) {
|
||||
pvs, count, err := packages.SearchVersions(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
pds, err := packages.GetPackageDescriptors(ctx, pvs)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
apiPackages := make([]*api.Package, 0, len(pds))
|
||||
for _, pd := range pds {
|
||||
apiPackage, err := convert.ToPackage(ctx, pd, ctx.Doer)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
apiPackages = append(apiPackages, apiPackage)
|
||||
}
|
||||
|
||||
return apiPackages, count, nil
|
||||
}
|
||||
1693
routers/api/v1/repo/action.go
Normal file
1693
routers/api/v1/repo/action.go
Normal file
File diff suppressed because it is too large
Load Diff
64
routers/api/v1/repo/actions_run.go
Normal file
64
routers/api/v1/repo/actions_run.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/routers/common"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
func DownloadActionsRunJobLogs(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/actions/jobs/{job_id}/logs repository downloadActionsRunJobLogs
|
||||
// ---
|
||||
// summary: Downloads the job logs for a workflow run
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repository
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: job_id
|
||||
// in: path
|
||||
// description: id of the job
|
||||
// type: integer
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// description: output blob content
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
jobID := ctx.PathParamInt64("job_id")
|
||||
curJob, err := actions_model.GetRunJobByID(ctx, jobID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if err = curJob.LoadRepo(ctx); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
err = common.DownloadActionsRunJobLogs(ctx.Base, ctx.Repo.Repository, curJob)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
88
routers/api/v1/repo/avatar.go
Normal file
88
routers/api/v1/repo/avatar.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
repo_service "code.gitea.io/gitea/services/repository"
|
||||
)
|
||||
|
||||
// UpdateVatar updates the Avatar of an Repo
|
||||
func UpdateAvatar(ctx *context.APIContext) {
|
||||
// swagger:operation POST /repos/{owner}/{repo}/avatar repository repoUpdateAvatar
|
||||
// ---
|
||||
// summary: Update avatar
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/UpdateRepoAvatarOption"
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
form := web.GetForm(ctx).(*api.UpdateRepoAvatarOption)
|
||||
|
||||
content, err := base64.StdEncoding.DecodeString(form.Image)
|
||||
if err != nil {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = repo_service.UploadAvatar(ctx, ctx.Repo.Repository, content)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// UpdateAvatar deletes the Avatar of an Repo
|
||||
func DeleteAvatar(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /repos/{owner}/{repo}/avatar repository repoDeleteAvatar
|
||||
// ---
|
||||
// summary: Delete avatar
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
err := repo_service.DeleteAvatar(ctx, ctx.Repo.Repository)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
55
routers/api/v1/repo/blob.go
Normal file
55
routers/api/v1/repo/blob.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/services/context"
|
||||
files_service "code.gitea.io/gitea/services/repository/files"
|
||||
)
|
||||
|
||||
// GetBlob get the blob of a repository file.
|
||||
func GetBlob(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/git/blobs/{sha} repository GetBlob
|
||||
// ---
|
||||
// summary: Gets the blob of a repository.
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: sha
|
||||
// in: path
|
||||
// description: sha of the commit
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/GitBlobResponse"
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
sha := ctx.PathParam("sha")
|
||||
if len(sha) == 0 {
|
||||
ctx.APIError(http.StatusBadRequest, "sha not provided")
|
||||
return
|
||||
}
|
||||
|
||||
if blob, err := files_service.GetBlobBySHA(ctx.Repo.Repository, ctx.Repo.GitRepo, sha); err != nil {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
} else {
|
||||
ctx.JSON(http.StatusOK, blob)
|
||||
}
|
||||
}
|
||||
1197
routers/api/v1/repo/branch.go
Normal file
1197
routers/api/v1/repo/branch.go
Normal file
File diff suppressed because it is too large
Load Diff
371
routers/api/v1/repo/collaborators.go
Normal file
371
routers/api/v1/repo/collaborators.go
Normal file
@@ -0,0 +1,371 @@
|
||||
// Copyright 2016 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
access_model "code.gitea.io/gitea/models/perm/access"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/routers/api/v1/utils"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/convert"
|
||||
issue_service "code.gitea.io/gitea/services/issue"
|
||||
pull_service "code.gitea.io/gitea/services/pull"
|
||||
repo_service "code.gitea.io/gitea/services/repository"
|
||||
)
|
||||
|
||||
// ListCollaborators list a repository's collaborators
|
||||
func ListCollaborators(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/collaborators repository repoListCollaborators
|
||||
// ---
|
||||
// summary: List a repository's collaborators
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/UserList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
collaborators, total, err := repo_model.GetCollaborators(ctx, &repo_model.FindCollaborationOptions{
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
users := make([]*api.User, len(collaborators))
|
||||
for i, collaborator := range collaborators {
|
||||
users[i] = convert.ToUser(ctx, collaborator.User, ctx.Doer)
|
||||
}
|
||||
|
||||
ctx.SetTotalCountHeader(total)
|
||||
ctx.JSON(http.StatusOK, users)
|
||||
}
|
||||
|
||||
// IsCollaborator check if a user is a collaborator of a repository
|
||||
func IsCollaborator(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/collaborators/{collaborator} repository repoCheckCollaborator
|
||||
// ---
|
||||
// summary: Check if a user is a collaborator of a repository
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: collaborator
|
||||
// in: path
|
||||
// description: username of the user to check for being a collaborator
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
user, err := user_model.GetUserByName(ctx, ctx.PathParam("collaborator"))
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
isColab, err := repo_model.IsCollaborator(ctx, ctx.Repo.Repository.ID, user.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if isColab {
|
||||
ctx.Status(http.StatusNoContent)
|
||||
} else {
|
||||
ctx.APIErrorNotFound()
|
||||
}
|
||||
}
|
||||
|
||||
// AddOrUpdateCollaborator add or update a collaborator to a repository
|
||||
func AddOrUpdateCollaborator(ctx *context.APIContext) {
|
||||
// swagger:operation PUT /repos/{owner}/{repo}/collaborators/{collaborator} repository repoAddCollaborator
|
||||
// ---
|
||||
// summary: Add or Update a collaborator to a repository
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: collaborator
|
||||
// in: path
|
||||
// description: username of the user to add or update as a collaborator
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/AddCollaboratorOption"
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
form := web.GetForm(ctx).(*api.AddCollaboratorOption)
|
||||
|
||||
collaborator, err := user_model.GetUserByName(ctx, ctx.PathParam("collaborator"))
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !collaborator.IsActive {
|
||||
ctx.APIErrorInternal(errors.New("collaborator's account is inactive"))
|
||||
return
|
||||
}
|
||||
|
||||
p := perm.AccessModeWrite
|
||||
if form.Permission != nil {
|
||||
p = perm.ParseAccessMode(*form.Permission, perm.AccessModeRead, perm.AccessModeWrite, perm.AccessModeAdmin)
|
||||
}
|
||||
|
||||
if err := repo_service.AddOrUpdateCollaborator(ctx, ctx.Repo.Repository, collaborator, p); err != nil {
|
||||
if errors.Is(err, user_model.ErrBlockedUser) {
|
||||
ctx.APIError(http.StatusForbidden, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// DeleteCollaborator delete a collaborator from a repository
|
||||
func DeleteCollaborator(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /repos/{owner}/{repo}/collaborators/{collaborator} repository repoDeleteCollaborator
|
||||
// ---
|
||||
// summary: Delete a collaborator from a repository
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: collaborator
|
||||
// in: path
|
||||
// description: username of the collaborator to delete
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
collaborator, err := user_model.GetUserByName(ctx, ctx.PathParam("collaborator"))
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := repo_service.DeleteCollaboration(ctx, ctx.Repo.Repository, collaborator); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetRepoPermissions gets repository permissions for a user
|
||||
func GetRepoPermissions(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/collaborators/{collaborator}/permission repository repoGetRepoPermissions
|
||||
// ---
|
||||
// summary: Get repository permissions for a user
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: collaborator
|
||||
// in: path
|
||||
// description: username of the collaborator whose permissions are to be obtained
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/RepoCollaboratorPermission"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
|
||||
collaboratorUsername := ctx.PathParam("collaborator")
|
||||
if !ctx.Doer.IsAdmin && !strings.EqualFold(ctx.Doer.LowerName, collaboratorUsername) && !ctx.IsUserRepoAdmin() {
|
||||
ctx.APIError(http.StatusForbidden, "Only admins can query all permissions, repo admins can query all repo permissions, collaborators can query only their own")
|
||||
return
|
||||
}
|
||||
|
||||
collaborator, err := user_model.GetUserByName(ctx, collaboratorUsername)
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
ctx.APIError(http.StatusNotFound, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
permission, err := access_model.GetUserRepoPermission(ctx, ctx.Repo.Repository, collaborator)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, convert.ToUserAndPermission(ctx, collaborator, ctx.ContextUser, permission.AccessMode))
|
||||
}
|
||||
|
||||
// GetReviewers return all users that can be requested to review in this repo
|
||||
func GetReviewers(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/reviewers repository repoGetReviewers
|
||||
// ---
|
||||
// summary: Return all users that can be requested to review in this repo
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/UserList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
canChooseReviewer := issue_service.CanDoerChangeReviewRequests(ctx, ctx.Doer, ctx.Repo.Repository, 0)
|
||||
if !canChooseReviewer {
|
||||
ctx.APIError(http.StatusForbidden, errors.New("doer has no permission to get reviewers"))
|
||||
return
|
||||
}
|
||||
|
||||
reviewers, err := pull_service.GetReviewers(ctx, ctx.Repo.Repository, ctx.Doer.ID, 0)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, convert.ToUsers(ctx, ctx.Doer, reviewers))
|
||||
}
|
||||
|
||||
// GetAssignees return all users that have write access and can be assigned to issues
|
||||
func GetAssignees(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/assignees repository repoGetAssignees
|
||||
// ---
|
||||
// summary: Return all users that have write access and can be assigned to issues
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/UserList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
assignees, err := repo_model.GetRepoAssignees(ctx, ctx.Repo.Repository)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, convert.ToUsers(ctx, ctx.Doer, assignees))
|
||||
}
|
||||
403
routers/api/v1/repo/commits.go
Normal file
403
routers/api/v1/repo/commits.go
Normal file
@@ -0,0 +1,403 @@
|
||||
// Copyright 2018 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/routers/api/v1/utils"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/convert"
|
||||
)
|
||||
|
||||
// GetSingleCommit get a commit via sha
|
||||
func GetSingleCommit(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/git/commits/{sha} repository repoGetSingleCommit
|
||||
// ---
|
||||
// summary: Get a single commit from a repository
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: sha
|
||||
// in: path
|
||||
// description: a git ref or commit sha
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: stat
|
||||
// in: query
|
||||
// description: include diff stats for every commit (disable for speedup, default 'true')
|
||||
// type: boolean
|
||||
// - name: verification
|
||||
// in: query
|
||||
// description: include verification for every commit (disable for speedup, default 'true')
|
||||
// type: boolean
|
||||
// - name: files
|
||||
// in: query
|
||||
// description: include a list of affected files for every commit (disable for speedup, default 'true')
|
||||
// type: boolean
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/Commit"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
sha := ctx.PathParam("sha")
|
||||
if !git.IsValidRefPattern(sha) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, "no valid ref or sha: "+sha)
|
||||
return
|
||||
}
|
||||
|
||||
getCommit(ctx, sha, convert.ParseCommitOptions(ctx))
|
||||
}
|
||||
|
||||
func getCommit(ctx *context.APIContext, identifier string, toCommitOpts convert.ToCommitOptions) {
|
||||
commit, err := ctx.Repo.GitRepo.GetCommit(identifier)
|
||||
if err != nil {
|
||||
if git.IsErrNotExist(err) {
|
||||
ctx.APIErrorNotFound("commit doesn't exist: " + identifier)
|
||||
return
|
||||
}
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
json, err := convert.ToCommit(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, commit, nil, toCommitOpts)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, json)
|
||||
}
|
||||
|
||||
// GetAllCommits get all commits via
|
||||
func GetAllCommits(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/commits repository repoGetAllCommits
|
||||
// ---
|
||||
// summary: Get a list of all commits from a repository
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: sha
|
||||
// in: query
|
||||
// description: SHA or branch to start listing commits from (usually 'master')
|
||||
// type: string
|
||||
// - name: path
|
||||
// in: query
|
||||
// description: filepath of a file/dir
|
||||
// type: string
|
||||
// - name: since
|
||||
// in: query
|
||||
// description: Only commits after this date will be returned (ISO 8601 format)
|
||||
// type: string
|
||||
// format: date-time
|
||||
// - name: until
|
||||
// in: query
|
||||
// description: Only commits before this date will be returned (ISO 8601 format)
|
||||
// type: string
|
||||
// format: date-time
|
||||
// - name: stat
|
||||
// in: query
|
||||
// description: include diff stats for every commit (disable for speedup, default 'true')
|
||||
// type: boolean
|
||||
// - name: verification
|
||||
// in: query
|
||||
// description: include verification for every commit (disable for speedup, default 'true')
|
||||
// type: boolean
|
||||
// - name: files
|
||||
// in: query
|
||||
// description: include a list of affected files for every commit (disable for speedup, default 'true')
|
||||
// type: boolean
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results (ignored if used with 'path')
|
||||
// type: integer
|
||||
// - name: not
|
||||
// in: query
|
||||
// description: commits that match the given specifier will not be listed.
|
||||
// type: string
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/CommitList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "409":
|
||||
// "$ref": "#/responses/EmptyRepository"
|
||||
|
||||
since := ctx.FormString("since")
|
||||
until := ctx.FormString("until")
|
||||
|
||||
// Validate since/until as ISO 8601 (RFC3339)
|
||||
if since != "" {
|
||||
if _, err := time.Parse(time.RFC3339, since); err != nil {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, "invalid 'since' format, expected ISO 8601 (RFC3339)")
|
||||
return
|
||||
}
|
||||
}
|
||||
if until != "" {
|
||||
if _, err := time.Parse(time.RFC3339, until); err != nil {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, "invalid 'until' format, expected ISO 8601 (RFC3339)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if ctx.Repo.Repository.IsEmpty {
|
||||
ctx.JSON(http.StatusConflict, api.APIError{
|
||||
Message: "Git Repository is empty.",
|
||||
URL: setting.API.SwaggerURL,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
if listOptions.Page <= 0 {
|
||||
listOptions.Page = 1
|
||||
}
|
||||
|
||||
if listOptions.PageSize > setting.Git.CommitsRangeSize {
|
||||
listOptions.PageSize = setting.Git.CommitsRangeSize
|
||||
}
|
||||
|
||||
sha := ctx.FormString("sha")
|
||||
path := ctx.FormString("path")
|
||||
not := ctx.FormString("not")
|
||||
|
||||
var (
|
||||
commitsCountTotal int64
|
||||
commits []*git.Commit
|
||||
err error
|
||||
)
|
||||
|
||||
if len(path) == 0 {
|
||||
var baseCommit *git.Commit
|
||||
if len(sha) == 0 {
|
||||
// no sha supplied - use default branch
|
||||
baseCommit, err = ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// get commit specified by sha
|
||||
baseCommit, err = ctx.Repo.GitRepo.GetCommit(sha)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Total commit count
|
||||
commitsCountTotal, err = git.CommitsCount(ctx.Repo.GitRepo.Ctx, git.CommitsCountOptions{
|
||||
RepoPath: ctx.Repo.GitRepo.Path,
|
||||
Not: not,
|
||||
Revision: []string{baseCommit.ID.String()},
|
||||
Since: since,
|
||||
Until: until,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Query commits
|
||||
commits, err = baseCommit.CommitsByRange(listOptions.Page, listOptions.PageSize, not, since, until)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if len(sha) == 0 {
|
||||
sha = ctx.Repo.Repository.DefaultBranch
|
||||
}
|
||||
|
||||
commitsCountTotal, err = git.CommitsCount(ctx,
|
||||
git.CommitsCountOptions{
|
||||
RepoPath: ctx.Repo.GitRepo.Path,
|
||||
Not: not,
|
||||
Revision: []string{sha},
|
||||
RelPath: []string{path},
|
||||
Since: since,
|
||||
Until: until,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
} else if commitsCountTotal == 0 {
|
||||
ctx.APIErrorNotFound("FileCommitsCount", nil)
|
||||
return
|
||||
}
|
||||
|
||||
commits, err = ctx.Repo.GitRepo.CommitsByFileAndRange(
|
||||
git.CommitsByFileAndRangeOptions{
|
||||
Revision: sha,
|
||||
File: path,
|
||||
Not: not,
|
||||
Page: listOptions.Page,
|
||||
Since: since,
|
||||
Until: until,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
pageCount := int(math.Ceil(float64(commitsCountTotal) / float64(listOptions.PageSize)))
|
||||
userCache := make(map[string]*user_model.User)
|
||||
apiCommits := make([]*api.Commit, len(commits))
|
||||
|
||||
for i, commit := range commits {
|
||||
// Create json struct
|
||||
apiCommits[i], err = convert.ToCommit(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, commit, userCache, convert.ParseCommitOptions(ctx))
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(commitsCountTotal), listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(commitsCountTotal)
|
||||
|
||||
// kept for backwards compatibility
|
||||
ctx.RespHeader().Set("X-Page", strconv.Itoa(listOptions.Page))
|
||||
ctx.RespHeader().Set("X-PerPage", strconv.Itoa(listOptions.PageSize))
|
||||
ctx.RespHeader().Set("X-Total", strconv.FormatInt(commitsCountTotal, 10))
|
||||
ctx.RespHeader().Set("X-PageCount", strconv.Itoa(pageCount))
|
||||
ctx.RespHeader().Set("X-HasMore", strconv.FormatBool(listOptions.Page < pageCount))
|
||||
ctx.AppendAccessControlExposeHeaders("X-Page", "X-PerPage", "X-Total", "X-PageCount", "X-HasMore")
|
||||
|
||||
ctx.JSON(http.StatusOK, &apiCommits)
|
||||
}
|
||||
|
||||
// DownloadCommitDiffOrPatch render a commit's raw diff or patch
|
||||
func DownloadCommitDiffOrPatch(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/git/commits/{sha}.{diffType} repository repoDownloadCommitDiffOrPatch
|
||||
// ---
|
||||
// summary: Get a commit's diff or patch
|
||||
// produces:
|
||||
// - text/plain
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: sha
|
||||
// in: path
|
||||
// description: SHA of the commit to get
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: diffType
|
||||
// in: path
|
||||
// description: whether the output is diff or patch
|
||||
// type: string
|
||||
// enum: [diff, patch]
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/string"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
sha := ctx.PathParam("sha")
|
||||
diffType := git.RawDiffType(ctx.PathParam("diffType"))
|
||||
|
||||
if err := git.GetRawDiff(ctx.Repo.GitRepo, sha, diffType, ctx.Resp); err != nil {
|
||||
if git.IsErrNotExist(err) {
|
||||
ctx.APIErrorNotFound("commit doesn't exist: " + sha)
|
||||
return
|
||||
}
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// GetCommitPullRequest returns the merged pull request of the commit
|
||||
func GetCommitPullRequest(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/commits/{sha}/pull repository repoGetCommitPullRequest
|
||||
// ---
|
||||
// summary: Get the merged pull request of the commit
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: sha
|
||||
// in: path
|
||||
// description: SHA of the commit to get
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/PullRequest"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
pr, err := issues_model.GetPullRequestByMergedCommit(ctx, ctx.Repo.Repository.ID, ctx.PathParam("sha"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrPullRequestNotExist(err) {
|
||||
ctx.APIError(http.StatusNotFound, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err = pr.LoadBaseRepo(ctx); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if err = pr.LoadHeadRepo(ctx); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, convert.ToAPIPullRequest(ctx, pr, ctx.Doer))
|
||||
}
|
||||
95
routers/api/v1/repo/compare.go
Normal file
95
routers/api/v1/repo/compare.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/gitrepo"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/convert"
|
||||
)
|
||||
|
||||
// CompareDiff compare two branches or commits
|
||||
func CompareDiff(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/compare/{basehead} repository repoCompareDiff
|
||||
// ---
|
||||
// summary: Get commit comparison information
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: basehead
|
||||
// in: path
|
||||
// description: compare two branches or commits
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/Compare"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
if ctx.Repo.GitRepo == nil {
|
||||
var err error
|
||||
ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx.Repo.Repository)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
infoPath := ctx.PathParam("*")
|
||||
infos := []string{ctx.Repo.Repository.DefaultBranch, ctx.Repo.Repository.DefaultBranch}
|
||||
if infoPath != "" {
|
||||
infos = strings.SplitN(infoPath, "...", 2)
|
||||
if len(infos) != 2 {
|
||||
if infos = strings.SplitN(infoPath, "..", 2); len(infos) != 2 {
|
||||
infos = []string{ctx.Repo.Repository.DefaultBranch, infoPath}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compareResult, closer := parseCompareInfo(ctx, api.CreatePullRequestOption{Base: infos[0], Head: infos[1]})
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
defer closer()
|
||||
|
||||
verification := ctx.FormString("verification") == "" || ctx.FormBool("verification")
|
||||
files := ctx.FormString("files") == "" || ctx.FormBool("files")
|
||||
|
||||
apiCommits := make([]*api.Commit, 0, len(compareResult.compareInfo.Commits))
|
||||
userCache := make(map[string]*user_model.User)
|
||||
for i := 0; i < len(compareResult.compareInfo.Commits); i++ {
|
||||
apiCommit, err := convert.ToCommit(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, compareResult.compareInfo.Commits[i], userCache,
|
||||
convert.ToCommitOptions{
|
||||
Stat: true,
|
||||
Verification: verification,
|
||||
Files: files,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
apiCommits = append(apiCommits, apiCommit)
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, &api.Compare{
|
||||
TotalCommits: len(compareResult.compareInfo.Commits),
|
||||
Commits: apiCommits,
|
||||
})
|
||||
}
|
||||
44
routers/api/v1/repo/download.go
Normal file
44
routers/api/v1/repo/download.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
archiver_service "code.gitea.io/gitea/services/repository/archiver"
|
||||
)
|
||||
|
||||
func serveRepoArchive(ctx *context.APIContext, reqFileName string) {
|
||||
aReq, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, reqFileName)
|
||||
if err != nil {
|
||||
if errors.Is(err, archiver_service.ErrUnknownArchiveFormat{}) {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
} else if errors.Is(err, archiver_service.RepoRefNotFoundError{}) {
|
||||
ctx.APIError(http.StatusNotFound, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
archiver_service.ServeRepoArchive(ctx.Base, ctx.Repo.Repository, ctx.Repo.GitRepo, aReq)
|
||||
}
|
||||
|
||||
func DownloadArchive(ctx *context.APIContext) {
|
||||
var tp git.ArchiveType
|
||||
switch ballType := ctx.PathParam("ball_type"); ballType {
|
||||
case "tarball":
|
||||
tp = git.ArchiveTarGz
|
||||
case "zipball":
|
||||
tp = git.ArchiveZip
|
||||
case "bundle":
|
||||
tp = git.ArchiveBundle
|
||||
default:
|
||||
ctx.APIError(http.StatusBadRequest, "Unknown archive type: "+ballType)
|
||||
return
|
||||
}
|
||||
serveRepoArchive(ctx, ctx.PathParam("*")+"."+tp.String())
|
||||
}
|
||||
982
routers/api/v1/repo/file.go
Normal file
982
routers/api/v1/repo/file.go
Normal file
@@ -0,0 +1,982 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
git_model "code.gitea.io/gitea/models/git"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/httpcache"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/lfs"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/storage"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/routers/api/v1/utils"
|
||||
"code.gitea.io/gitea/routers/common"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
pull_service "code.gitea.io/gitea/services/pull"
|
||||
files_service "code.gitea.io/gitea/services/repository/files"
|
||||
)
|
||||
|
||||
const giteaObjectTypeHeader = "X-Gitea-Object-Type"
|
||||
|
||||
// GetRawFile get a file by path on a repository
|
||||
func GetRawFile(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/raw/{filepath} repository repoGetRawFile
|
||||
// ---
|
||||
// summary: Get a file from a repository
|
||||
// produces:
|
||||
// - application/octet-stream
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: filepath
|
||||
// in: path
|
||||
// description: path of the file to get, it should be "{ref}/{filepath}". If there is no ref could be inferred, it will be treated as the default branch
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: ref
|
||||
// in: query
|
||||
// description: "The name of the commit/branch/tag. Default to the repository’s default branch"
|
||||
// type: string
|
||||
// required: false
|
||||
// responses:
|
||||
// 200:
|
||||
// description: Returns raw file content.
|
||||
// schema:
|
||||
// type: file
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
if ctx.Repo.Repository.IsEmpty {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
blob, entry, lastModified := getBlobForEntry(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.RespHeader().Set(giteaObjectTypeHeader, string(files_service.GetObjectTypeFromTreeEntry(entry)))
|
||||
|
||||
if err := common.ServeBlob(ctx.Base, ctx.Repo.Repository, ctx.Repo.TreePath, blob, lastModified); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// GetRawFileOrLFS get a file by repo's path, redirecting to LFS if necessary.
|
||||
func GetRawFileOrLFS(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/media/{filepath} repository repoGetRawFileOrLFS
|
||||
// ---
|
||||
// summary: Get a file or it's LFS object from a repository
|
||||
// produces:
|
||||
// - application/octet-stream
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: filepath
|
||||
// in: path
|
||||
// description: path of the file to get, it should be "{ref}/{filepath}". If there is no ref could be inferred, it will be treated as the default branch
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: ref
|
||||
// in: query
|
||||
// description: "The name of the commit/branch/tag. Default to the repository’s default branch"
|
||||
// type: string
|
||||
// required: false
|
||||
// responses:
|
||||
// 200:
|
||||
// description: Returns raw file content.
|
||||
// schema:
|
||||
// type: file
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
if ctx.Repo.Repository.IsEmpty {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
blob, entry, lastModified := getBlobForEntry(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.RespHeader().Set(giteaObjectTypeHeader, string(files_service.GetObjectTypeFromTreeEntry(entry)))
|
||||
|
||||
// LFS Pointer files are at most 1024 bytes - so any blob greater than 1024 bytes cannot be an LFS file
|
||||
if blob.Size() > lfs.MetaFileMaxSize {
|
||||
// First handle caching for the blob
|
||||
if httpcache.HandleGenericETagTimeCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) {
|
||||
return
|
||||
}
|
||||
|
||||
// If not cached - serve!
|
||||
if err := common.ServeBlob(ctx.Base, ctx.Repo.Repository, ctx.Repo.TreePath, blob, lastModified); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// OK, now the blob is known to have at most 1024 (lfs pointer max size) bytes,
|
||||
// we can simply read this in one go (This saves reading it twice)
|
||||
dataRc, err := blob.DataAsync()
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
buf, err := io.ReadAll(dataRc)
|
||||
if err != nil {
|
||||
_ = dataRc.Close()
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := dataRc.Close(); err != nil {
|
||||
log.Error("Error whilst closing blob %s reader in %-v. Error: %v", blob.ID, ctx.Repo.Repository, err)
|
||||
}
|
||||
|
||||
// Check if the blob represents a pointer
|
||||
pointer, _ := lfs.ReadPointer(bytes.NewReader(buf))
|
||||
|
||||
// if it's not a pointer, just serve the data directly
|
||||
if !pointer.IsValid() {
|
||||
// First handle caching for the blob
|
||||
if httpcache.HandleGenericETagTimeCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) {
|
||||
return
|
||||
}
|
||||
|
||||
// If not cached - serve!
|
||||
common.ServeContentByReader(ctx.Base, ctx.Repo.TreePath, blob.Size(), bytes.NewReader(buf))
|
||||
return
|
||||
}
|
||||
|
||||
// Now check if there is a MetaObject for this pointer
|
||||
meta, err := git_model.GetLFSMetaObjectByOid(ctx, ctx.Repo.Repository.ID, pointer.Oid)
|
||||
|
||||
// If there isn't one, just serve the data directly
|
||||
if errors.Is(err, git_model.ErrLFSObjectNotExist) {
|
||||
// Handle caching for the blob SHA (not the LFS object OID)
|
||||
if httpcache.HandleGenericETagTimeCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) {
|
||||
return
|
||||
}
|
||||
|
||||
common.ServeContentByReader(ctx.Base, ctx.Repo.TreePath, blob.Size(), bytes.NewReader(buf))
|
||||
return
|
||||
} else if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle caching for the LFS object OID
|
||||
if httpcache.HandleGenericETagCache(ctx.Req, ctx.Resp, `"`+pointer.Oid+`"`) {
|
||||
return
|
||||
}
|
||||
|
||||
if setting.LFS.Storage.ServeDirect() {
|
||||
// If we have a signed url (S3, object storage), redirect to this directly.
|
||||
u, err := storage.LFS.URL(pointer.RelativePath(), blob.Name(), ctx.Req.Method, nil)
|
||||
if u != nil && err == nil {
|
||||
ctx.Redirect(u.String())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
lfsDataRc, err := lfs.ReadMetaObject(meta.Pointer)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
defer lfsDataRc.Close()
|
||||
|
||||
common.ServeContentByReadSeeker(ctx.Base, ctx.Repo.TreePath, lastModified, lfsDataRc)
|
||||
}
|
||||
|
||||
func getBlobForEntry(ctx *context.APIContext) (blob *git.Blob, entry *git.TreeEntry, lastModified *time.Time) {
|
||||
entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath)
|
||||
if err != nil {
|
||||
if git.IsErrNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
if entry.IsDir() || entry.IsSubModule() {
|
||||
ctx.APIErrorNotFound("getBlobForEntry", nil)
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
latestCommit, err := ctx.Repo.GitRepo.GetTreePathLatestCommit(ctx.Repo.Commit.ID.String(), ctx.Repo.TreePath)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return nil, nil, nil
|
||||
}
|
||||
when := &latestCommit.Committer.When
|
||||
|
||||
return entry.Blob(), entry, when
|
||||
}
|
||||
|
||||
// GetArchive get archive of a repository
|
||||
func GetArchive(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/archive/{archive} repository repoGetArchive
|
||||
// ---
|
||||
// summary: Get an archive of a repository
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: archive
|
||||
// in: path
|
||||
// description: the git reference for download with attached archive format (e.g. master.zip)
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// 200:
|
||||
// description: success
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
serveRepoArchive(ctx, ctx.PathParam("*"))
|
||||
}
|
||||
|
||||
// GetEditorconfig get editor config of a repository
|
||||
func GetEditorconfig(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/editorconfig/{filepath} repository repoGetEditorConfig
|
||||
// ---
|
||||
// summary: Get the EditorConfig definitions of a file in a repository
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: filepath
|
||||
// in: path
|
||||
// description: filepath of file to get
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: ref
|
||||
// in: query
|
||||
// description: "The name of the commit/branch/tag. Default to the repository’s default branch."
|
||||
// type: string
|
||||
// required: false
|
||||
// responses:
|
||||
// 200:
|
||||
// description: success
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
ec, _, err := ctx.Repo.GetEditorconfig(ctx.Repo.Commit)
|
||||
if err != nil {
|
||||
if git.IsErrNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
fileName := ctx.PathParam("filename")
|
||||
def, err := ec.GetDefinitionForFilename(fileName)
|
||||
if def == nil {
|
||||
ctx.APIErrorNotFound(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, def)
|
||||
}
|
||||
|
||||
func base64Reader(s string) (io.ReadSeeker, error) {
|
||||
b, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bytes.NewReader(b), nil
|
||||
}
|
||||
|
||||
func ReqChangeRepoFileOptionsAndCheck(ctx *context.APIContext) {
|
||||
commonOpts := web.GetForm(ctx).(api.FileOptionsInterface).GetFileOptions()
|
||||
commonOpts.BranchName = util.IfZero(commonOpts.BranchName, ctx.Repo.Repository.DefaultBranch)
|
||||
commonOpts.NewBranchName = util.IfZero(commonOpts.NewBranchName, commonOpts.BranchName)
|
||||
if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, commonOpts.NewBranchName) && !ctx.IsUserSiteAdmin() {
|
||||
ctx.APIError(http.StatusForbidden, "user should have a permission to write to the target branch")
|
||||
return
|
||||
}
|
||||
changeFileOpts := &files_service.ChangeRepoFilesOptions{
|
||||
Message: commonOpts.Message,
|
||||
OldBranch: commonOpts.BranchName,
|
||||
NewBranch: commonOpts.NewBranchName,
|
||||
Committer: &files_service.IdentityOptions{
|
||||
GitUserName: commonOpts.Committer.Name,
|
||||
GitUserEmail: commonOpts.Committer.Email,
|
||||
},
|
||||
Author: &files_service.IdentityOptions{
|
||||
GitUserName: commonOpts.Author.Name,
|
||||
GitUserEmail: commonOpts.Author.Email,
|
||||
},
|
||||
Dates: &files_service.CommitDateOptions{
|
||||
Author: commonOpts.Dates.Author,
|
||||
Committer: commonOpts.Dates.Committer,
|
||||
},
|
||||
Signoff: commonOpts.Signoff,
|
||||
}
|
||||
if commonOpts.Dates.Author.IsZero() {
|
||||
commonOpts.Dates.Author = time.Now()
|
||||
}
|
||||
if commonOpts.Dates.Committer.IsZero() {
|
||||
commonOpts.Dates.Committer = time.Now()
|
||||
}
|
||||
ctx.Data["__APIChangeRepoFilesOptions"] = changeFileOpts
|
||||
}
|
||||
|
||||
func getAPIChangeRepoFileOptions[T api.FileOptionsInterface](ctx *context.APIContext) (apiOpts T, opts *files_service.ChangeRepoFilesOptions) {
|
||||
return web.GetForm(ctx).(T), ctx.Data["__APIChangeRepoFilesOptions"].(*files_service.ChangeRepoFilesOptions)
|
||||
}
|
||||
|
||||
// ChangeFiles handles API call for modifying multiple files
|
||||
func ChangeFiles(ctx *context.APIContext) {
|
||||
// swagger:operation POST /repos/{owner}/{repo}/contents repository repoChangeFiles
|
||||
// ---
|
||||
// summary: Modify multiple files in a repository
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ChangeFilesOptions"
|
||||
// responses:
|
||||
// "201":
|
||||
// "$ref": "#/responses/FilesResponse"
|
||||
// "403":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/error"
|
||||
// "423":
|
||||
// "$ref": "#/responses/repoArchivedError"
|
||||
apiOpts, opts := getAPIChangeRepoFileOptions[*api.ChangeFilesOptions](ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
for _, file := range apiOpts.Files {
|
||||
contentReader, err := base64Reader(file.ContentBase64)
|
||||
if err != nil {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
return
|
||||
}
|
||||
// FIXME: ChangeFileOperation.SHA is NOT required for update or delete if last commit is provided in the options
|
||||
// But the LastCommitID is not provided in the API options, need to fully fix them in API
|
||||
changeRepoFile := &files_service.ChangeRepoFile{
|
||||
Operation: file.Operation,
|
||||
TreePath: file.Path,
|
||||
FromTreePath: file.FromPath,
|
||||
ContentReader: contentReader,
|
||||
SHA: file.SHA,
|
||||
}
|
||||
opts.Files = append(opts.Files, changeRepoFile)
|
||||
}
|
||||
|
||||
if opts.Message == "" {
|
||||
opts.Message = changeFilesCommitMessage(ctx, opts.Files)
|
||||
}
|
||||
|
||||
if filesResponse, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, opts); err != nil {
|
||||
handleChangeRepoFilesError(ctx, err)
|
||||
} else {
|
||||
ctx.JSON(http.StatusCreated, filesResponse)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateFile handles API call for creating a file
|
||||
func CreateFile(ctx *context.APIContext) {
|
||||
// swagger:operation POST /repos/{owner}/{repo}/contents/{filepath} repository repoCreateFile
|
||||
// ---
|
||||
// summary: Create a file in a repository
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: filepath
|
||||
// in: path
|
||||
// description: path of the file to create
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/CreateFileOptions"
|
||||
// responses:
|
||||
// "201":
|
||||
// "$ref": "#/responses/FileResponse"
|
||||
// "403":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/error"
|
||||
// "423":
|
||||
// "$ref": "#/responses/repoArchivedError"
|
||||
|
||||
apiOpts, opts := getAPIChangeRepoFileOptions[*api.CreateFileOptions](ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
contentReader, err := base64Reader(apiOpts.ContentBase64)
|
||||
if err != nil {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
return
|
||||
}
|
||||
|
||||
opts.Files = append(opts.Files, &files_service.ChangeRepoFile{
|
||||
Operation: "create",
|
||||
TreePath: ctx.PathParam("*"),
|
||||
ContentReader: contentReader,
|
||||
})
|
||||
if opts.Message == "" {
|
||||
opts.Message = changeFilesCommitMessage(ctx, opts.Files)
|
||||
}
|
||||
|
||||
if filesResponse, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, opts); err != nil {
|
||||
handleChangeRepoFilesError(ctx, err)
|
||||
} else {
|
||||
fileResponse := files_service.GetFileResponseFromFilesResponse(filesResponse, 0)
|
||||
ctx.JSON(http.StatusCreated, fileResponse)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateFile handles API call for updating a file
|
||||
func UpdateFile(ctx *context.APIContext) {
|
||||
// swagger:operation PUT /repos/{owner}/{repo}/contents/{filepath} repository repoUpdateFile
|
||||
// ---
|
||||
// summary: Update a file in a repository
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: filepath
|
||||
// in: path
|
||||
// description: path of the file to update
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/UpdateFileOptions"
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/FileResponse"
|
||||
// "403":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/error"
|
||||
// "423":
|
||||
// "$ref": "#/responses/repoArchivedError"
|
||||
|
||||
apiOpts, opts := getAPIChangeRepoFileOptions[*api.UpdateFileOptions](ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
contentReader, err := base64Reader(apiOpts.ContentBase64)
|
||||
if err != nil {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
return
|
||||
}
|
||||
opts.Files = append(opts.Files, &files_service.ChangeRepoFile{
|
||||
Operation: "update",
|
||||
ContentReader: contentReader,
|
||||
SHA: apiOpts.SHA,
|
||||
FromTreePath: apiOpts.FromPath,
|
||||
TreePath: ctx.PathParam("*"),
|
||||
})
|
||||
if opts.Message == "" {
|
||||
opts.Message = changeFilesCommitMessage(ctx, opts.Files)
|
||||
}
|
||||
|
||||
if filesResponse, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, opts); err != nil {
|
||||
handleChangeRepoFilesError(ctx, err)
|
||||
} else {
|
||||
fileResponse := files_service.GetFileResponseFromFilesResponse(filesResponse, 0)
|
||||
ctx.JSON(http.StatusOK, fileResponse)
|
||||
}
|
||||
}
|
||||
|
||||
func handleChangeRepoFilesError(ctx *context.APIContext, err error) {
|
||||
if files_service.IsErrUserCannotCommit(err) || pull_service.IsErrFilePathProtected(err) {
|
||||
ctx.APIError(http.StatusForbidden, err)
|
||||
return
|
||||
}
|
||||
if git_model.IsErrBranchAlreadyExists(err) || files_service.IsErrFilenameInvalid(err) || pull_service.IsErrSHADoesNotMatch(err) ||
|
||||
files_service.IsErrFilePathInvalid(err) || files_service.IsErrRepoFileAlreadyExists(err) ||
|
||||
files_service.IsErrCommitIDDoesNotMatch(err) || files_service.IsErrSHAOrCommitIDNotProvided(err) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
return
|
||||
}
|
||||
if git.IsErrBranchNotExist(err) || files_service.IsErrRepoFileDoesNotExist(err) || git.IsErrNotExist(err) {
|
||||
ctx.APIError(http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIError(http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
|
||||
// format commit message if empty
|
||||
func changeFilesCommitMessage(ctx *context.APIContext, files []*files_service.ChangeRepoFile) string {
|
||||
var (
|
||||
createFiles []string
|
||||
updateFiles []string
|
||||
deleteFiles []string
|
||||
)
|
||||
for _, file := range files {
|
||||
switch file.Operation {
|
||||
case "create":
|
||||
createFiles = append(createFiles, file.TreePath)
|
||||
case "update", "upload", "rename": // upload and rename works like "update", there is no translation for them at the moment
|
||||
updateFiles = append(updateFiles, file.TreePath)
|
||||
case "delete":
|
||||
deleteFiles = append(deleteFiles, file.TreePath)
|
||||
}
|
||||
}
|
||||
message := ""
|
||||
if len(createFiles) != 0 {
|
||||
message += ctx.Locale.TrString("repo.editor.add", strings.Join(createFiles, ", ")+"\n")
|
||||
}
|
||||
if len(updateFiles) != 0 {
|
||||
message += ctx.Locale.TrString("repo.editor.update", strings.Join(updateFiles, ", ")+"\n")
|
||||
}
|
||||
if len(deleteFiles) != 0 {
|
||||
message += ctx.Locale.TrString("repo.editor.delete", strings.Join(deleteFiles, ", "))
|
||||
}
|
||||
return strings.Trim(message, "\n")
|
||||
}
|
||||
|
||||
// DeleteFile Delete a file in a repository
|
||||
func DeleteFile(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /repos/{owner}/{repo}/contents/{filepath} repository repoDeleteFile
|
||||
// ---
|
||||
// summary: Delete a file in a repository
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: filepath
|
||||
// in: path
|
||||
// description: path of the file to delete
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/DeleteFileOptions"
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/FileDeleteResponse"
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "403":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/error"
|
||||
// "422":
|
||||
// "$ref": "#/responses/error"
|
||||
// "423":
|
||||
// "$ref": "#/responses/repoArchivedError"
|
||||
|
||||
apiOpts, opts := getAPIChangeRepoFileOptions[*api.DeleteFileOptions](ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
opts.Files = append(opts.Files, &files_service.ChangeRepoFile{
|
||||
Operation: "delete",
|
||||
SHA: apiOpts.SHA,
|
||||
TreePath: ctx.PathParam("*"),
|
||||
})
|
||||
if opts.Message == "" {
|
||||
opts.Message = changeFilesCommitMessage(ctx, opts.Files)
|
||||
}
|
||||
|
||||
if filesResponse, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, opts); err != nil {
|
||||
handleChangeRepoFilesError(ctx, err)
|
||||
} else {
|
||||
fileResponse := files_service.GetFileResponseFromFilesResponse(filesResponse, 0)
|
||||
ctx.JSON(http.StatusOK, fileResponse) // FIXME on APIv2: return http.StatusNoContent
|
||||
}
|
||||
}
|
||||
|
||||
func resolveRefCommit(ctx *context.APIContext, ref string, minCommitIDLen ...int) *utils.RefCommit {
|
||||
ref = util.IfZero(ref, ctx.Repo.Repository.DefaultBranch)
|
||||
refCommit, err := utils.ResolveRefCommit(ctx, ctx.Repo.Repository, ref, minCommitIDLen...)
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return refCommit
|
||||
}
|
||||
|
||||
func GetContentsExt(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/contents-ext/{filepath} repository repoGetContentsExt
|
||||
// ---
|
||||
// summary: The extended "contents" API, to get file metadata and/or content, or list a directory.
|
||||
// description: It guarantees that only one of the response fields is set if the request succeeds.
|
||||
// Users can pass "includes=file_content" or "includes=lfs_metadata" to retrieve more fields.
|
||||
// "includes=file_content" only works for single file, if you need to retrieve file contents in batch,
|
||||
// use "file-contents" API after listing the directory.
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: filepath
|
||||
// in: path
|
||||
// description: path of the dir, file, symlink or submodule in the repo. Swagger requires path parameter to be "required",
|
||||
// you can leave it empty or pass a single dot (".") to get the root directory.
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: ref
|
||||
// in: query
|
||||
// description: the name of the commit/branch/tag, default to the repository’s default branch.
|
||||
// type: string
|
||||
// required: false
|
||||
// - name: includes
|
||||
// in: query
|
||||
// description: By default this API's response only contains file's metadata. Use comma-separated "includes" options to retrieve more fields.
|
||||
// Option "file_content" will try to retrieve the file content, "lfs_metadata" will try to retrieve LFS metadata,
|
||||
// "commit_metadata" will try to retrieve commit metadata, and "commit_message" will try to retrieve commit message.
|
||||
// type: string
|
||||
// required: false
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/ContentsExtResponse"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
if treePath := ctx.PathParam("*"); treePath == "." || treePath == "/" {
|
||||
ctx.SetPathParam("*", "") // workaround for swagger, it requires path parameter to be "required", but we need to list root directory
|
||||
}
|
||||
opts := files_service.GetContentsOrListOptions{TreePath: ctx.PathParam("*")}
|
||||
for includeOpt := range strings.SplitSeq(ctx.FormString("includes"), ",") {
|
||||
if includeOpt == "" {
|
||||
continue
|
||||
}
|
||||
switch includeOpt {
|
||||
case "file_content":
|
||||
opts.IncludeSingleFileContent = true
|
||||
case "lfs_metadata":
|
||||
opts.IncludeLfsMetadata = true
|
||||
case "commit_metadata":
|
||||
opts.IncludeCommitMetadata = true
|
||||
case "commit_message":
|
||||
opts.IncludeCommitMessage = true
|
||||
default:
|
||||
ctx.APIError(http.StatusBadRequest, fmt.Sprintf("unknown include option %q", includeOpt))
|
||||
return
|
||||
}
|
||||
}
|
||||
ctx.JSON(http.StatusOK, getRepoContents(ctx, opts))
|
||||
}
|
||||
|
||||
func GetContents(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/contents/{filepath} repository repoGetContents
|
||||
// ---
|
||||
// summary: Gets the metadata and contents (if a file) of an entry in a repository, or a list of entries if a dir.
|
||||
// description: This API follows GitHub's design, and it is not easy to use. Recommend users to use the "contents-ext" API instead.
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: filepath
|
||||
// in: path
|
||||
// description: path of the dir, file, symlink or submodule in the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: ref
|
||||
// in: query
|
||||
// description: "The name of the commit/branch/tag. Default to the repository’s default branch."
|
||||
// type: string
|
||||
// required: false
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/ContentsResponse"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
ret := getRepoContents(ctx, files_service.GetContentsOrListOptions{
|
||||
TreePath: ctx.PathParam("*"),
|
||||
IncludeSingleFileContent: true,
|
||||
IncludeCommitMetadata: true,
|
||||
})
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, util.Iif[any](ret.FileContents != nil, ret.FileContents, ret.DirContents))
|
||||
}
|
||||
|
||||
func getRepoContents(ctx *context.APIContext, opts files_service.GetContentsOrListOptions) *api.ContentsExtResponse {
|
||||
refCommit := resolveRefCommit(ctx, ctx.FormTrim("ref"))
|
||||
if ctx.Written() {
|
||||
return nil
|
||||
}
|
||||
ret, err := files_service.GetContentsOrList(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, refCommit, opts)
|
||||
if err != nil {
|
||||
if git.IsErrNotExist(err) {
|
||||
ctx.APIErrorNotFound("GetContentsOrList", err)
|
||||
return nil
|
||||
}
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return &ret
|
||||
}
|
||||
|
||||
func GetContentsList(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/contents repository repoGetContentsList
|
||||
// ---
|
||||
// summary: Gets the metadata of all the entries of the root dir.
|
||||
// description: This API follows GitHub's design, and it is not easy to use. Recommend users to use our "contents-ext" API instead.
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: ref
|
||||
// in: query
|
||||
// description: "The name of the commit/branch/tag. Default to the repository’s default branch."
|
||||
// type: string
|
||||
// required: false
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/ContentsListResponse"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
// same as GetContents(), this function is here because swagger fails if path is empty in GetContents() interface
|
||||
GetContents(ctx)
|
||||
}
|
||||
|
||||
func GetFileContentsGet(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/file-contents repository repoGetFileContents
|
||||
// ---
|
||||
// summary: Get the metadata and contents of requested files
|
||||
// description: See the POST method. This GET method supports using JSON encoded request body in query parameter.
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: ref
|
||||
// in: query
|
||||
// description: "The name of the commit/branch/tag. Default to the repository’s default branch."
|
||||
// type: string
|
||||
// required: false
|
||||
// - name: body
|
||||
// in: query
|
||||
// description: "The JSON encoded body (see the POST request): {\"files\": [\"filename1\", \"filename2\"]}"
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/ContentsListResponse"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
// The POST method requires "write" permission, so we also support this "GET" method
|
||||
handleGetFileContents(ctx)
|
||||
}
|
||||
|
||||
func GetFileContentsPost(ctx *context.APIContext) {
|
||||
// swagger:operation POST /repos/{owner}/{repo}/file-contents repository repoGetFileContentsPost
|
||||
// ---
|
||||
// summary: Get the metadata and contents of requested files
|
||||
// description: Uses automatic pagination based on default page size and
|
||||
// max response size and returns the maximum allowed number of files.
|
||||
// Files which could not be retrieved are null. Files which are too large
|
||||
// are being returned with `encoding == null`, `content == null` and `size > 0`,
|
||||
// they can be requested separately by using the `download_url`.
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: ref
|
||||
// in: query
|
||||
// description: "The name of the commit/branch/tag. Default to the repository’s default branch."
|
||||
// type: string
|
||||
// required: false
|
||||
// - name: body
|
||||
// in: body
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/GetFilesOptions"
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/ContentsListResponse"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
// This is actually a "read" request, but we need to accept a "files" list, then POST method seems easy to use.
|
||||
// But the permission system requires that the caller must have "write" permission to use POST method.
|
||||
// At the moment, there is no other way to get around the permission check, so there is a "GET" workaround method above.
|
||||
handleGetFileContents(ctx)
|
||||
}
|
||||
|
||||
func handleGetFileContents(ctx *context.APIContext) {
|
||||
opts, ok := web.GetForm(ctx).(*api.GetFilesOptions)
|
||||
if !ok {
|
||||
err := json.Unmarshal(util.UnsafeStringToBytes(ctx.FormString("body")), &opts)
|
||||
if err != nil {
|
||||
ctx.APIError(http.StatusBadRequest, "invalid body parameter")
|
||||
return
|
||||
}
|
||||
}
|
||||
refCommit := resolveRefCommit(ctx, ctx.FormTrim("ref"))
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
filesResponse := files_service.GetContentsListFromTreePaths(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, refCommit, opts.Files)
|
||||
ctx.JSON(http.StatusOK, util.SliceNilAsEmpty(filesResponse))
|
||||
}
|
||||
173
routers/api/v1/repo/fork.go
Normal file
173
routers/api/v1/repo/fork.go
Normal file
@@ -0,0 +1,173 @@
|
||||
// Copyright 2016 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2020 The Gitea Authors.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
access_model "code.gitea.io/gitea/models/perm/access"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/routers/api/v1/utils"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/convert"
|
||||
repo_service "code.gitea.io/gitea/services/repository"
|
||||
)
|
||||
|
||||
// ListForks list a repository's forks
|
||||
func ListForks(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/forks repository listForks
|
||||
// ---
|
||||
// summary: List a repository's forks
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/RepositoryList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
forks, total, err := repo_service.FindForks(ctx, ctx.Repo.Repository, ctx.Doer, utils.GetListOptions(ctx))
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if err := repo_model.RepositoryList(forks).LoadOwners(ctx); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if err := repo_model.RepositoryList(forks).LoadUnits(ctx); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
apiForks := make([]*api.Repository, len(forks))
|
||||
for i, fork := range forks {
|
||||
permission, err := access_model.GetUserRepoPermission(ctx, fork, ctx.Doer)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
apiForks[i] = convert.ToRepo(ctx, fork, permission)
|
||||
}
|
||||
|
||||
ctx.SetTotalCountHeader(total)
|
||||
ctx.JSON(http.StatusOK, apiForks)
|
||||
}
|
||||
|
||||
// CreateFork create a fork of a repo
|
||||
func CreateFork(ctx *context.APIContext) {
|
||||
// swagger:operation POST /repos/{owner}/{repo}/forks repository createFork
|
||||
// ---
|
||||
// summary: Fork a repository
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo to fork
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo to fork
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/CreateForkOption"
|
||||
// responses:
|
||||
// "202":
|
||||
// "$ref": "#/responses/Repository"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "409":
|
||||
// description: The repository with the same name already exists.
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
form := web.GetForm(ctx).(*api.CreateForkOption)
|
||||
repo := ctx.Repo.Repository
|
||||
var forker *user_model.User // user/org that will own the fork
|
||||
if form.Organization == nil {
|
||||
forker = ctx.Doer
|
||||
} else {
|
||||
org, err := organization.GetOrgByName(ctx, *form.Organization)
|
||||
if err != nil {
|
||||
if organization.IsErrOrgNotExist(err) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !ctx.Doer.IsAdmin {
|
||||
isMember, err := org.IsOrgMember(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
} else if !isMember {
|
||||
ctx.APIError(http.StatusForbidden, fmt.Sprintf("User is no Member of Organisation '%s'", org.Name))
|
||||
return
|
||||
}
|
||||
}
|
||||
forker = org.AsUser()
|
||||
}
|
||||
|
||||
var name string
|
||||
if form.Name == nil {
|
||||
name = repo.Name
|
||||
} else {
|
||||
name = *form.Name
|
||||
}
|
||||
|
||||
fork, err := repo_service.ForkRepository(ctx, ctx.Doer, forker, repo_service.ForkRepoOptions{
|
||||
BaseRepo: repo,
|
||||
Name: name,
|
||||
Description: repo.Description,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrAlreadyExist) || repo_model.IsErrReachLimitOfRepo(err) {
|
||||
ctx.APIError(http.StatusConflict, err)
|
||||
} else if errors.Is(err, user_model.ErrBlockedUser) {
|
||||
ctx.APIError(http.StatusForbidden, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// TODO change back to 201
|
||||
ctx.JSON(http.StatusAccepted, convert.ToRepo(ctx, fork, access_model.Permission{AccessMode: perm.AccessModeOwner}))
|
||||
}
|
||||
197
routers/api/v1/repo/git_hook.go
Normal file
197
routers/api/v1/repo/git_hook.go
Normal file
@@ -0,0 +1,197 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/convert"
|
||||
)
|
||||
|
||||
// ListGitHooks list all Git hooks of a repository
|
||||
func ListGitHooks(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/hooks/git repository repoListGitHooks
|
||||
// ---
|
||||
// summary: List the Git hooks in a repository
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/GitHookList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
hooks, err := ctx.Repo.GitRepo.Hooks()
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
apiHooks := make([]*api.GitHook, len(hooks))
|
||||
for i := range hooks {
|
||||
apiHooks[i] = convert.ToGitHook(hooks[i])
|
||||
}
|
||||
ctx.JSON(http.StatusOK, &apiHooks)
|
||||
}
|
||||
|
||||
// GetGitHook get a repo's Git hook by id
|
||||
func GetGitHook(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/hooks/git/{id} repository repoGetGitHook
|
||||
// ---
|
||||
// summary: Get a Git hook
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the hook to get
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/GitHook"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
hookID := ctx.PathParam("id")
|
||||
hook, err := ctx.Repo.GitRepo.GetHook(hookID)
|
||||
if err != nil {
|
||||
if errors.Is(err, git.ErrNotValidHook) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, convert.ToGitHook(hook))
|
||||
}
|
||||
|
||||
// EditGitHook modify a Git hook of a repository
|
||||
func EditGitHook(ctx *context.APIContext) {
|
||||
// swagger:operation PATCH /repos/{owner}/{repo}/hooks/git/{id} repository repoEditGitHook
|
||||
// ---
|
||||
// summary: Edit a Git hook in a repository
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the hook to get
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/EditGitHookOption"
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/GitHook"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
form := web.GetForm(ctx).(*api.EditGitHookOption)
|
||||
hookID := ctx.PathParam("id")
|
||||
hook, err := ctx.Repo.GitRepo.GetHook(hookID)
|
||||
if err != nil {
|
||||
if errors.Is(err, git.ErrNotValidHook) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
hook.Content = form.Content
|
||||
if err = hook.Update(); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, convert.ToGitHook(hook))
|
||||
}
|
||||
|
||||
// DeleteGitHook delete a Git hook of a repository
|
||||
func DeleteGitHook(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /repos/{owner}/{repo}/hooks/git/{id} repository repoDeleteGitHook
|
||||
// ---
|
||||
// summary: Delete a Git hook in a repository
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the hook to get
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
hookID := ctx.PathParam("id")
|
||||
hook, err := ctx.Repo.GitRepo.GetHook(hookID)
|
||||
if err != nil {
|
||||
if errors.Is(err, git.ErrNotValidHook) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
hook.Content = ""
|
||||
if err = hook.Update(); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
108
routers/api/v1/repo/git_ref.go
Normal file
108
routers/api/v1/repo/git_ref.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/routers/api/v1/utils"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
// GetGitAllRefs get ref or an list all the refs of a repository
|
||||
func GetGitAllRefs(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/git/refs repository repoListAllGitRefs
|
||||
// ---
|
||||
// summary: Get specified ref or filtered repository's refs
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// # "$ref": "#/responses/Reference" TODO: swagger doesnt support different output formats by ref
|
||||
// "$ref": "#/responses/ReferenceList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
getGitRefsInternal(ctx, "")
|
||||
}
|
||||
|
||||
// GetGitRefs get ref or an filteresd list of refs of a repository
|
||||
func GetGitRefs(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/git/refs/{ref} repository repoListGitRefs
|
||||
// ---
|
||||
// summary: Get specified ref or filtered repository's refs
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: ref
|
||||
// in: path
|
||||
// description: part or full name of the ref
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// # "$ref": "#/responses/Reference" TODO: swagger doesnt support different output formats by ref
|
||||
// "$ref": "#/responses/ReferenceList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
getGitRefsInternal(ctx, ctx.PathParam("*"))
|
||||
}
|
||||
|
||||
func getGitRefsInternal(ctx *context.APIContext, filter string) {
|
||||
refs, lastMethodName, err := utils.GetGitRefs(ctx, filter)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(fmt.Errorf("%s: %w", lastMethodName, err))
|
||||
return
|
||||
}
|
||||
|
||||
if len(refs) == 0 {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
apiRefs := make([]*api.Reference, len(refs))
|
||||
for i := range refs {
|
||||
apiRefs[i] = &api.Reference{
|
||||
Ref: refs[i].Name,
|
||||
URL: ctx.Repo.Repository.APIURL() + "/git/" + util.PathEscapeSegments(refs[i].Name),
|
||||
Object: &api.GitObject{
|
||||
SHA: refs[i].Object.String(),
|
||||
Type: refs[i].Type,
|
||||
URL: ctx.Repo.Repository.APIURL() + "/git/" + url.PathEscape(refs[i].Type) + "s/" + url.PathEscape(refs[i].Object.String()),
|
||||
},
|
||||
}
|
||||
}
|
||||
// If single reference is found and it matches filter exactly return it as object
|
||||
if len(apiRefs) == 1 && apiRefs[0].Ref == filter {
|
||||
ctx.JSON(http.StatusOK, &apiRefs[0])
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, &apiRefs)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user