Files
zeekayandHanzo Dev aa87442fdd Record upstream authorship centrally in NOTICE instead of in every file header
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the
Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in
NOTICE, alongside LICENSE. The header lint accepts any copyright line above the
SPDX line, so it no longer demands an upstream one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 17:50:35 -07:00

485 lines
14 KiB
Go

// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/hanzoai/git/models/db"
repo_model "github.com/hanzoai/git/models/repo"
"github.com/hanzoai/git/models/unit"
"github.com/hanzoai/git/modules/git"
"github.com/hanzoai/git/modules/log"
"github.com/hanzoai/git/modules/setting"
api "github.com/hanzoai/git/modules/structs"
"github.com/hanzoai/git/modules/util"
"github.com/hanzoai/git/modules/web"
"github.com/hanzoai/git/routers/api/v1/utils"
"github.com/hanzoai/git/services/context"
"github.com/hanzoai/git/services/convert"
"github.com/hanzoai/git/services/migrations"
mirror_service "github.com/hanzoai/git/services/mirror"
repo_service "github.com/hanzoai/git/services/repository"
)
// MirrorSync adds a mirrored repository to the sync queue
func MirrorSync(ctx *context.APIContext) {
// swagger:operation POST /repos/{owner}/{repo}/mirror-sync repository repoMirrorSync
// ---
// summary: Sync a mirrored repository
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo to sync
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo to sync
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
repo := ctx.Repo.Repository
if !ctx.Repo.Permission.CanWrite(unit.TypeCode) {
ctx.APIError(http.StatusForbidden, "Must have write access")
}
if !setting.Mirror.Enabled {
ctx.APIError(http.StatusBadRequest, "Mirror feature is disabled")
return
}
if _, err := repo_model.GetMirrorByRepoID(ctx, repo.ID); err != nil {
if errors.Is(err, repo_model.ErrMirrorNotExist) {
ctx.APIError(http.StatusBadRequest, "Repository is not a mirror")
return
}
ctx.APIErrorInternal(err)
return
}
mirror_service.AddPullMirrorToQueue(repo.ID)
ctx.Status(http.StatusOK)
}
// PushMirrorSync adds all push mirrored repositories to the sync queue
func PushMirrorSync(ctx *context.APIContext) {
// swagger:operation POST /repos/{owner}/{repo}/push_mirrors-sync repository repoPushMirrorSync
// ---
// summary: Sync all push mirrored repository
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo to sync
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo to sync
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/empty"
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
if !setting.Mirror.Enabled {
ctx.APIError(http.StatusBadRequest, "Mirror feature is disabled")
return
}
// Get All push mirrors of a specific repo
pushMirrors, _, err := repo_model.GetPushMirrorsByRepoID(ctx, ctx.Repo.Repository.ID, db.ListOptions{})
if err != nil {
ctx.APIError(http.StatusNotFound, err.Error())
return
}
failedPushMirrors := make([]string, 0)
for _, mirror := range pushMirrors {
ok := mirror_service.SyncPushMirror(ctx, mirror.ID)
if !ok {
failedPushMirrors = append(failedPushMirrors, mirror.RemoteName)
}
}
if len(failedPushMirrors) != 0 {
ctx.APIError(http.StatusUnprocessableEntity, "error occurred when syncing push mirrors: "+strings.Join(failedPushMirrors, ", "))
return
}
ctx.Status(http.StatusOK)
}
// ListPushMirrors get list of push mirrors of a repository
func ListPushMirrors(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/push_mirrors repository repoListPushMirrors
// ---
// summary: Get all push mirrors of the 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: 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/PushMirrorList"
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
if !setting.Mirror.Enabled {
ctx.APIError(http.StatusBadRequest, "Mirror feature is disabled")
return
}
repo := ctx.Repo.Repository
// Get all push mirrors for the specified repository.
pushMirrors, count, err := repo_model.GetPushMirrorsByRepoID(ctx, repo.ID, utils.GetListOptions(ctx))
if err != nil {
ctx.APIError(http.StatusNotFound, err.Error())
return
}
responsePushMirrors := make([]*api.PushMirror, 0, len(pushMirrors))
for _, mirror := range pushMirrors {
m, err := convert.ToPushMirror(ctx, mirror)
if err == nil {
responsePushMirrors = append(responsePushMirrors, m)
}
}
ctx.SetLinkHeader(int64(len(responsePushMirrors)), utils.GetListOptions(ctx).PageSize)
ctx.SetTotalCountHeader(count)
ctx.JSON(http.StatusOK, responsePushMirrors)
}
// GetPushMirrorByName get push mirror of a repository by name
func GetPushMirrorByName(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/push_mirrors/{name} repository repoGetPushMirrorByRemoteName
// ---
// summary: Get push mirror of the repository by remoteName
// 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: name
// in: path
// description: remote name of push mirror
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/PushMirror"
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
if !setting.Mirror.Enabled {
ctx.APIError(http.StatusBadRequest, "Mirror feature is disabled")
return
}
mirrorName := ctx.PathParam("name")
// Get push mirror of a specific repo by remoteName
pushMirror, exist, err := db.Get[repo_model.PushMirror](ctx, repo_model.PushMirrorOptions{
RepoID: ctx.Repo.Repository.ID,
RemoteName: mirrorName,
}.ToConds())
if err != nil {
ctx.APIErrorInternal(err)
return
} else if !exist {
ctx.APIErrorNotFound()
return
}
m, err := convert.ToPushMirror(ctx, pushMirror)
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, m)
}
// AddPushMirror adds a push mirror to a repository
func AddPushMirror(ctx *context.APIContext) {
// swagger:operation POST /repos/{owner}/{repo}/push_mirrors repository repoAddPushMirror
// ---
// summary: add a push mirror to the 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
// schema:
// "$ref": "#/definitions/CreatePushMirrorOption"
// responses:
// "200":
// "$ref": "#/responses/PushMirror"
// "403":
// "$ref": "#/responses/forbidden"
// "400":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
if !setting.Mirror.Enabled {
ctx.APIError(http.StatusBadRequest, "Mirror feature is disabled")
return
}
pushMirror := web.GetForm(ctx).(*api.CreatePushMirrorOption)
CreatePushMirror(ctx, pushMirror)
}
// DeletePushMirrorByRemoteName deletes a push mirror from a repository by remoteName
func DeletePushMirrorByRemoteName(ctx *context.APIContext) {
// swagger:operation DELETE /repos/{owner}/{repo}/push_mirrors/{name} repository repoDeletePushMirror
// ---
// summary: deletes a push mirror from a repository by remoteName
// 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: name
// in: path
// description: remote name of the pushMirror
// type: string
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "404":
// "$ref": "#/responses/notFound"
// "400":
// "$ref": "#/responses/error"
if !setting.Mirror.Enabled {
ctx.APIError(http.StatusBadRequest, "Mirror feature is disabled")
return
}
remoteName := ctx.PathParam("name")
// Delete push mirror on repo by name.
err := repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{RepoID: ctx.Repo.Repository.ID, RemoteName: remoteName})
if err != nil {
ctx.APIError(http.StatusNotFound, err.Error())
return
}
ctx.Status(http.StatusNoContent)
}
func CreatePushMirror(ctx *context.APIContext, mirrorOption *api.CreatePushMirrorOption) {
repo := ctx.Repo.Repository
interval, err := time.ParseDuration(mirrorOption.Interval)
if err != nil {
ctx.APIError(http.StatusBadRequest, fmt.Sprintf("invalid interval: %v", err))
return
}
if interval != 0 && interval < setting.Mirror.MinInterval {
ctx.APIError(http.StatusBadRequest, fmt.Sprintf("interval is shorter than minimum %v", setting.Mirror.MinInterval.String()))
return
}
address, err := git.ParseRemoteAddr(mirrorOption.RemoteAddress, mirrorOption.RemoteUsername, mirrorOption.RemotePassword)
if err == nil {
err = migrations.IsMigrateURLAllowed(address, ctx.ContextUser)
}
if err != nil {
HandleRemoteAddressError(ctx, err)
return
}
remoteSuffix := util.CryptoRandomString(10)
remoteAddress, err := util.SanitizeURL(mirrorOption.RemoteAddress)
if err != nil {
ctx.APIErrorInternal(err)
return
}
pushMirror := &repo_model.PushMirror{
RepoID: repo.ID,
Repo: repo,
RemoteName: "remote_mirror_" + remoteSuffix,
Interval: interval,
SyncOnCommit: mirrorOption.SyncOnCommit,
RemoteAddress: remoteAddress,
}
if err = db.Insert(ctx, pushMirror); err != nil {
ctx.APIErrorInternal(err)
return
}
// if the registration of the push mirrorOption fails remove it from the database
if err = mirror_service.AddPushMirrorRemote(ctx, pushMirror, address); err != nil {
if err := repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{ID: pushMirror.ID, RepoID: pushMirror.RepoID}); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.APIErrorInternal(err)
return
}
m, err := convert.ToPushMirror(ctx, pushMirror)
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, m)
}
func HandleRemoteAddressError(ctx *context.APIContext, err error) {
if git.IsErrInvalidCloneAddr(err) {
addrErr := err.(*git.ErrInvalidCloneAddr)
switch {
case addrErr.IsProtocolInvalid:
ctx.APIError(http.StatusBadRequest, "Invalid mirror protocol")
case addrErr.IsURLError:
ctx.APIError(http.StatusBadRequest, "Invalid Url ")
case addrErr.IsPermissionDenied:
ctx.APIError(http.StatusUnauthorized, "Permission denied")
default:
ctx.APIError(http.StatusBadRequest, "Unknown error")
}
return
}
}
// ConvertPullMirror turns a pull mirror into a regular repository.
func ConvertPullMirror(ctx *context.APIContext) {
// swagger:operation DELETE /repos/{owner}/{repo}/pull-mirror repository repoConvertPullMirror
// ---
// summary: Convert a pull mirror into a regular repository
// description: |
// Deletes the pull-mirror configuration, so the repository stops being
// overwritten from upstream and starts accepting pushes and running
// Actions. This is the API form of the web settings action.
//
// Propagation from upstream STOPS the moment this returns. The mirror row
// is gone, so mirror-sync answers 400 and the settings button 404, while
// the /sync webhook receiver still answers ok without queueing anything —
// nothing pulls from upstream again. A repository whose copy here is
// incomplete therefore becomes authoritative while incomplete, so compare
// it against upstream before converting.
// 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"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
repo := ctx.Repo.Repository
// Loud rather than idempotent: a batch converting many repositories needs to
// tell "already regular" apart from "converted just now", and a silent
// success would report progress it did not make.
if !repo.IsMirror {
ctx.APIError(http.StatusUnprocessableEntity, "repository is not a pull mirror")
return
}
repo.IsMirror = false
if _, err := repo_service.CleanUpMigrateInfo(ctx, repo); err != nil {
ctx.APIErrorInternal(err)
return
}
if err := repo_model.DeleteMirrorByRepoID(ctx, repo.ID); err != nil {
ctx.APIErrorInternal(err)
return
}
log.Trace("Repository converted from mirror to regular: %s", repo.FullName())
ctx.Status(http.StatusNoContent)
}