Files
forge/routers/web/repo/attachment.go
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

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"))
}