mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
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]>
247 lines
7.0 KiB
Go
247 lines
7.0 KiB
Go
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package repo
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
auth_model "github.com/hanzoai/git/models/auth"
|
|
issues_model "github.com/hanzoai/git/models/issues"
|
|
access_model "github.com/hanzoai/git/models/perm/access"
|
|
repo_model "github.com/hanzoai/git/models/repo"
|
|
"github.com/hanzoai/git/models/unit"
|
|
"github.com/hanzoai/git/modules/httpcache"
|
|
"github.com/hanzoai/git/modules/httplib"
|
|
"github.com/hanzoai/git/modules/log"
|
|
"github.com/hanzoai/git/modules/setting"
|
|
"github.com/hanzoai/git/modules/storage"
|
|
"github.com/hanzoai/git/services/attachment"
|
|
"github.com/hanzoai/git/services/context"
|
|
"github.com/hanzoai/git/services/context/upload"
|
|
repo_service "github.com/hanzoai/git/services/repository"
|
|
)
|
|
|
|
func attachmentReadScope(unitType unit.Type) (auth_model.AccessTokenScope, bool) {
|
|
switch unitType {
|
|
case unit.TypeIssues, unit.TypePullRequests:
|
|
return auth_model.AccessTokenScopeReadIssue, true
|
|
case unit.TypeReleases:
|
|
return auth_model.AccessTokenScopeReadRepository, true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
// UploadIssueAttachment response for Issue/PR attachments
|
|
func UploadIssueAttachment(ctx *context.Context) {
|
|
uploadAttachment(ctx, ctx.Repo.Repository.ID, attachment.UploadAttachmentForIssue)
|
|
}
|
|
|
|
// UploadReleaseAttachment response for uploading release attachments
|
|
func UploadReleaseAttachment(ctx *context.Context) {
|
|
uploadAttachment(ctx, ctx.Repo.Repository.ID, attachment.UploadAttachmentForRelease)
|
|
}
|
|
|
|
// UploadAttachment response for uploading attachments
|
|
func uploadAttachment(ctx *context.Context, repoID int64, uploadFunc attachment.UploadAttachmentFunc) {
|
|
if !setting.Attachment.Enabled {
|
|
ctx.HTTPError(http.StatusNotFound, "attachment is not enabled")
|
|
return
|
|
}
|
|
|
|
file, header, err := ctx.Req.FormFile("file")
|
|
if err != nil {
|
|
ctx.ServerError("FormFile", err)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
uploaderFile := attachment.NewLimitedUploaderKnownSize(file, header.Size)
|
|
attach, err := uploadFunc(ctx, uploaderFile, &repo_model.Attachment{
|
|
Name: header.Filename,
|
|
UploaderID: ctx.Doer.ID,
|
|
RepoID: repoID,
|
|
})
|
|
if err != nil {
|
|
if upload.IsErrFileTypeForbidden(err) {
|
|
ctx.HTTPError(http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
ctx.ServerError("uploadAttachment(uploadFunc)", err)
|
|
return
|
|
}
|
|
|
|
log.Trace("New attachment uploaded: %s", attach.UUID)
|
|
ctx.JSON(http.StatusOK, map[string]string{
|
|
"uuid": attach.UUID,
|
|
})
|
|
}
|
|
|
|
// DeleteAttachment response for deleting issue's attachment
|
|
func DeleteAttachment(ctx *context.Context) {
|
|
file := ctx.FormString("file")
|
|
attach, err := repo_model.GetAttachmentByUUID(ctx, file)
|
|
if err != nil {
|
|
ctx.HTTPError(http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
if !ctx.IsSigned {
|
|
ctx.HTTPError(http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if attach.RepoID != ctx.Repo.Repository.ID {
|
|
ctx.HTTPError(http.StatusBadRequest, "attachment does not belong to this repository")
|
|
return
|
|
}
|
|
|
|
if ctx.Doer.ID != attach.UploaderID {
|
|
if attach.IssueID > 0 {
|
|
issue, err := issues_model.GetIssueByID(ctx, attach.IssueID)
|
|
if err != nil {
|
|
ctx.ServerError("GetIssueByID", err)
|
|
return
|
|
}
|
|
if !ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) {
|
|
ctx.HTTPError(http.StatusForbidden)
|
|
return
|
|
}
|
|
} else if attach.ReleaseID > 0 {
|
|
if !ctx.Repo.Permission.CanWrite(unit.TypeReleases) {
|
|
ctx.HTTPError(http.StatusForbidden)
|
|
return
|
|
}
|
|
} else {
|
|
if !ctx.Repo.Permission.IsAdmin() && !ctx.Repo.Permission.IsOwner() {
|
|
ctx.HTTPError(http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
err = repo_model.DeleteAttachment(ctx, attach, true)
|
|
if err != nil {
|
|
ctx.ServerError("DeleteAttachment", err)
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, map[string]string{
|
|
"uuid": attach.UUID,
|
|
})
|
|
}
|
|
|
|
// ServeAttachment serve attachments with the given UUID
|
|
func ServeAttachment(ctx *context.Context, uuid string) {
|
|
attach, err := repo_model.GetAttachmentByUUID(ctx, uuid)
|
|
if err != nil {
|
|
if repo_model.IsErrAttachmentNotExist(err) {
|
|
ctx.HTTPError(http.StatusNotFound)
|
|
} else {
|
|
ctx.ServerError("GetAttachmentByUUID", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
// prevent visiting attachment from other repository directly
|
|
// The check will be ignored before this code merged.
|
|
if attach.CreatedUnix > repo_model.LegacyAttachmentMissingRepoIDCutoff && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID != attach.RepoID {
|
|
ctx.HTTPError(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
unitType, repoID, err := repo_service.GetAttachmentLinkedTypeAndRepoID(ctx, attach)
|
|
if err != nil {
|
|
ctx.ServerError("GetAttachmentLinkedTypeAndRepoID", err)
|
|
return
|
|
}
|
|
|
|
if unitType == unit.TypeInvalid { // unlinked attachment can only be accessed by the uploader
|
|
if !(ctx.IsSigned && attach.UploaderID == ctx.Doer.ID) { // We block if not the uploader
|
|
ctx.HTTPError(http.StatusNotFound)
|
|
return
|
|
}
|
|
} else { // If we have the linked type, we need to check access
|
|
var (
|
|
perm access_model.Permission
|
|
repo = ctx.Repo.Repository
|
|
)
|
|
if repo == nil {
|
|
repo, err = repo_model.GetRepositoryByID(ctx, repoID)
|
|
if err != nil {
|
|
ctx.ServerError("GetRepositoryByID", err)
|
|
return
|
|
}
|
|
perm, err = access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer)
|
|
if err != nil {
|
|
ctx.ServerError("GetDoerRepoPermission", err)
|
|
return
|
|
}
|
|
} else {
|
|
perm = ctx.Repo.Permission
|
|
}
|
|
|
|
if !perm.CanRead(unitType) {
|
|
ctx.HTTPError(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Draft release attachments must not be exposed to anyone without write
|
|
// access, matching the API-side canAccessReleaseDraft gate. Otherwise the
|
|
// UUID-based web endpoints would leak draft attachments to any recipient of
|
|
// the (leaked) download URL.
|
|
if unitType == unit.TypeReleases && attach.ReleaseID != 0 && !perm.CanWrite(unit.TypeReleases) {
|
|
rel, err := repo_model.GetReleaseByID(ctx, attach.ReleaseID)
|
|
if err != nil {
|
|
ctx.ServerError("GetReleaseByID", err)
|
|
return
|
|
}
|
|
if rel.IsDraft {
|
|
ctx.HTTPError(http.StatusNotFound)
|
|
return
|
|
}
|
|
}
|
|
|
|
if requiredScope, ok := attachmentReadScope(unitType); ok {
|
|
context.CheckTokenScopes(ctx, repo, requiredScope)
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := attach.IncreaseDownloadCount(ctx); err != nil {
|
|
ctx.ServerError("IncreaseDownloadCount", err)
|
|
return
|
|
}
|
|
|
|
if setting.Attachment.Storage.ServeDirect() {
|
|
// If we have a signed url (S3, object storage), redirect to this directly.
|
|
u, err := storage.Attachments.ServeDirectURL(attach.RelativePath(), attach.Name, ctx.Req.Method, nil)
|
|
|
|
if u != nil && err == nil {
|
|
ctx.Redirect(u.String())
|
|
return
|
|
}
|
|
}
|
|
|
|
if httpcache.HandleGenericETagPrivateCache(ctx.Req, ctx.Resp, `"`+attach.UUID+`"`, attach.CreatedUnix.AsTimePtr()) {
|
|
return
|
|
}
|
|
|
|
// If we have matched and access to release or issue
|
|
fr, err := storage.Attachments.Open(attach.RelativePath())
|
|
if err != nil {
|
|
ctx.ServerError("Open", err)
|
|
return
|
|
}
|
|
defer fr.Close()
|
|
|
|
httplib.ServeUserContentByFile(ctx.Req, ctx.Resp, fr, httplib.ServeHeaderOptions{Filename: attach.Name})
|
|
}
|
|
|
|
// GetAttachment serve attachments
|
|
func GetAttachment(ctx *context.Context) {
|
|
ServeAttachment(ctx, ctx.PathParam("uuid"))
|
|
}
|