From 89017418dbd8e71daecfa4e172cdf02263164822 Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sat, 28 Feb 2026 23:02:25 +0100 Subject: [PATCH 01/78] Feature non zipped actions artifacts --- models/actions/artifact.go | 2 +- modules/actions/artifacts.go | 6 +- routers/api/actions/artifact.pb.go | 607 ++++++++---------------- routers/api/actions/artifact.proto | 3 + routers/api/actions/artifacts_chunks.go | 36 +- routers/api/actions/artifactsv4.go | 41 +- routers/web/repo/actions/view.go | 5 +- 7 files changed, 250 insertions(+), 450 deletions(-) diff --git a/models/actions/artifact.go b/models/actions/artifact.go index 757bd13acd7f1..aa2c2380e6f1b 100644 --- a/models/actions/artifact.go +++ b/models/actions/artifact.go @@ -156,7 +156,7 @@ func (opts FindArtifactsOptions) ToConds() builder.Cond { } if opts.FinalizedArtifactsV4 { cond = cond.And(builder.Eq{"status": ArtifactStatusUploadConfirmed}.Or(builder.Eq{"status": ArtifactStatusExpired})) - cond = cond.And(builder.Eq{"content_encoding": "application/zip"}) + cond = cond.And(builder.Like{"content_encoding", "/"}) } return cond diff --git a/modules/actions/artifacts.go b/modules/actions/artifacts.go index d28726e89931f..88d3066750b45 100644 --- a/modules/actions/artifacts.go +++ b/modules/actions/artifacts.go @@ -5,6 +5,7 @@ package actions import ( "net/http" + "strings" actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/modules/setting" @@ -15,7 +16,7 @@ import ( // Artifacts using the v4 backend are stored as a single combined zip file per artifact on the backend // The v4 backend ensures ContentEncoding is set to "application/zip", which is not the case for the old backend func IsArtifactV4(art *actions_model.ActionArtifact) bool { - return art.ArtifactName+".zip" == art.ArtifactPath && art.ContentEncoding == "application/zip" + return strings.Contains(art.ContentEncoding, "/") } func DownloadArtifactV4ServeDirectOnly(ctx *context.Base, art *actions_model.ActionArtifact) (bool, error) { @@ -35,7 +36,8 @@ func DownloadArtifactV4Fallback(ctx *context.Base, art *actions_model.ActionArti return err } defer f.Close() - http.ServeContent(ctx.Resp, ctx.Req, art.ArtifactName+".zip", art.CreatedUnix.AsLocalTime(), f) + ctx.Resp.Header().Set("Content-Type", art.ContentEncoding) + http.ServeContent(ctx.Resp, ctx.Req, art.ArtifactPath, art.CreatedUnix.AsLocalTime(), f) return nil } diff --git a/routers/api/actions/artifact.pb.go b/routers/api/actions/artifact.pb.go index 590eda9fb9a08..130e20301fafa 100644 --- a/routers/api/actions/artifact.pb.go +++ b/routers/api/actions/artifact.pb.go @@ -3,8 +3,8 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.32.0 -// protoc v4.25.2 +// protoc-gen-go v1.36.11 +// protoc v7.34.0 // source: artifact.proto package actions @@ -12,6 +12,7 @@ package actions import ( reflect "reflect" sync "sync" + unsafe "unsafe" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" @@ -27,24 +28,22 @@ const ( ) type CreateArtifactRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - WorkflowRunBackendId string `protobuf:"bytes,1,opt,name=workflow_run_backend_id,json=workflowRunBackendId,proto3" json:"workflow_run_backend_id,omitempty"` - WorkflowJobRunBackendId string `protobuf:"bytes,2,opt,name=workflow_job_run_backend_id,json=workflowJobRunBackendId,proto3" json:"workflow_job_run_backend_id,omitempty"` - Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` - ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` - Version int32 `protobuf:"varint,5,opt,name=version,proto3" json:"version,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + WorkflowRunBackendId string `protobuf:"bytes,1,opt,name=workflow_run_backend_id,json=workflowRunBackendId,proto3" json:"workflow_run_backend_id,omitempty"` + WorkflowJobRunBackendId string `protobuf:"bytes,2,opt,name=workflow_job_run_backend_id,json=workflowJobRunBackendId,proto3" json:"workflow_job_run_backend_id,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + Version int32 `protobuf:"varint,5,opt,name=version,proto3" json:"version,omitempty"` + MimeType *wrapperspb.StringValue `protobuf:"bytes,6,opt,name=mime_type,json=mimeType,proto3" json:"mime_type,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *CreateArtifactRequest) Reset() { *x = CreateArtifactRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_artifact_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_artifact_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *CreateArtifactRequest) String() string { @@ -55,7 +54,7 @@ func (*CreateArtifactRequest) ProtoMessage() {} func (x *CreateArtifactRequest) ProtoReflect() protoreflect.Message { mi := &file_artifact_proto_msgTypes[0] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -105,22 +104,26 @@ func (x *CreateArtifactRequest) GetVersion() int32 { return 0 } -type CreateArtifactResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields +func (x *CreateArtifactRequest) GetMimeType() *wrapperspb.StringValue { + if x != nil { + return x.MimeType + } + return nil +} - Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` - SignedUploadUrl string `protobuf:"bytes,2,opt,name=signed_upload_url,json=signedUploadUrl,proto3" json:"signed_upload_url,omitempty"` +type CreateArtifactResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + SignedUploadUrl string `protobuf:"bytes,2,opt,name=signed_upload_url,json=signedUploadUrl,proto3" json:"signed_upload_url,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *CreateArtifactResponse) Reset() { *x = CreateArtifactResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_artifact_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_artifact_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *CreateArtifactResponse) String() string { @@ -131,7 +134,7 @@ func (*CreateArtifactResponse) ProtoMessage() {} func (x *CreateArtifactResponse) ProtoReflect() protoreflect.Message { mi := &file_artifact_proto_msgTypes[1] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -161,24 +164,21 @@ func (x *CreateArtifactResponse) GetSignedUploadUrl() string { } type FinalizeArtifactRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - + state protoimpl.MessageState `protogen:"open.v1"` WorkflowRunBackendId string `protobuf:"bytes,1,opt,name=workflow_run_backend_id,json=workflowRunBackendId,proto3" json:"workflow_run_backend_id,omitempty"` WorkflowJobRunBackendId string `protobuf:"bytes,2,opt,name=workflow_job_run_backend_id,json=workflowJobRunBackendId,proto3" json:"workflow_job_run_backend_id,omitempty"` Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` Size int64 `protobuf:"varint,4,opt,name=size,proto3" json:"size,omitempty"` Hash *wrapperspb.StringValue `protobuf:"bytes,5,opt,name=hash,proto3" json:"hash,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *FinalizeArtifactRequest) Reset() { *x = FinalizeArtifactRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_artifact_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_artifact_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *FinalizeArtifactRequest) String() string { @@ -189,7 +189,7 @@ func (*FinalizeArtifactRequest) ProtoMessage() {} func (x *FinalizeArtifactRequest) ProtoReflect() protoreflect.Message { mi := &file_artifact_proto_msgTypes[2] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -240,21 +240,18 @@ func (x *FinalizeArtifactRequest) GetHash() *wrapperspb.StringValue { } type FinalizeArtifactResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + ArtifactId int64 `protobuf:"varint,2,opt,name=artifact_id,json=artifactId,proto3" json:"artifact_id,omitempty"` unknownFields protoimpl.UnknownFields - - Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` - ArtifactId int64 `protobuf:"varint,2,opt,name=artifact_id,json=artifactId,proto3" json:"artifact_id,omitempty"` + sizeCache protoimpl.SizeCache } func (x *FinalizeArtifactResponse) Reset() { *x = FinalizeArtifactResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_artifact_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_artifact_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *FinalizeArtifactResponse) String() string { @@ -265,7 +262,7 @@ func (*FinalizeArtifactResponse) ProtoMessage() {} func (x *FinalizeArtifactResponse) ProtoReflect() protoreflect.Message { mi := &file_artifact_proto_msgTypes[3] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -295,23 +292,20 @@ func (x *FinalizeArtifactResponse) GetArtifactId() int64 { } type ListArtifactsRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - + state protoimpl.MessageState `protogen:"open.v1"` WorkflowRunBackendId string `protobuf:"bytes,1,opt,name=workflow_run_backend_id,json=workflowRunBackendId,proto3" json:"workflow_run_backend_id,omitempty"` WorkflowJobRunBackendId string `protobuf:"bytes,2,opt,name=workflow_job_run_backend_id,json=workflowJobRunBackendId,proto3" json:"workflow_job_run_backend_id,omitempty"` NameFilter *wrapperspb.StringValue `protobuf:"bytes,3,opt,name=name_filter,json=nameFilter,proto3" json:"name_filter,omitempty"` IdFilter *wrapperspb.Int64Value `protobuf:"bytes,4,opt,name=id_filter,json=idFilter,proto3" json:"id_filter,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ListArtifactsRequest) Reset() { *x = ListArtifactsRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_artifact_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_artifact_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *ListArtifactsRequest) String() string { @@ -322,7 +316,7 @@ func (*ListArtifactsRequest) ProtoMessage() {} func (x *ListArtifactsRequest) ProtoReflect() protoreflect.Message { mi := &file_artifact_proto_msgTypes[4] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -366,20 +360,17 @@ func (x *ListArtifactsRequest) GetIdFilter() *wrapperspb.Int64Value { } type ListArtifactsResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Artifacts []*ListArtifactsResponse_MonolithArtifact `protobuf:"bytes,1,rep,name=artifacts,proto3" json:"artifacts,omitempty"` unknownFields protoimpl.UnknownFields - - Artifacts []*ListArtifactsResponse_MonolithArtifact `protobuf:"bytes,1,rep,name=artifacts,proto3" json:"artifacts,omitempty"` + sizeCache protoimpl.SizeCache } func (x *ListArtifactsResponse) Reset() { *x = ListArtifactsResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_artifact_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_artifact_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *ListArtifactsResponse) String() string { @@ -390,7 +381,7 @@ func (*ListArtifactsResponse) ProtoMessage() {} func (x *ListArtifactsResponse) ProtoReflect() protoreflect.Message { mi := &file_artifact_proto_msgTypes[5] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -413,25 +404,22 @@ func (x *ListArtifactsResponse) GetArtifacts() []*ListArtifactsResponse_Monolith } type ListArtifactsResponse_MonolithArtifact struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - + state protoimpl.MessageState `protogen:"open.v1"` WorkflowRunBackendId string `protobuf:"bytes,1,opt,name=workflow_run_backend_id,json=workflowRunBackendId,proto3" json:"workflow_run_backend_id,omitempty"` WorkflowJobRunBackendId string `protobuf:"bytes,2,opt,name=workflow_job_run_backend_id,json=workflowJobRunBackendId,proto3" json:"workflow_job_run_backend_id,omitempty"` DatabaseId int64 `protobuf:"varint,3,opt,name=database_id,json=databaseId,proto3" json:"database_id,omitempty"` Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"` Size int64 `protobuf:"varint,5,opt,name=size,proto3" json:"size,omitempty"` CreatedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ListArtifactsResponse_MonolithArtifact) Reset() { *x = ListArtifactsResponse_MonolithArtifact{} - if protoimpl.UnsafeEnabled { - mi := &file_artifact_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_artifact_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *ListArtifactsResponse_MonolithArtifact) String() string { @@ -442,7 +430,7 @@ func (*ListArtifactsResponse_MonolithArtifact) ProtoMessage() {} func (x *ListArtifactsResponse_MonolithArtifact) ProtoReflect() protoreflect.Message { mi := &file_artifact_proto_msgTypes[6] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -500,22 +488,19 @@ func (x *ListArtifactsResponse_MonolithArtifact) GetCreatedAt() *timestamppb.Tim } type GetSignedArtifactURLRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - WorkflowRunBackendId string `protobuf:"bytes,1,opt,name=workflow_run_backend_id,json=workflowRunBackendId,proto3" json:"workflow_run_backend_id,omitempty"` - WorkflowJobRunBackendId string `protobuf:"bytes,2,opt,name=workflow_job_run_backend_id,json=workflowJobRunBackendId,proto3" json:"workflow_job_run_backend_id,omitempty"` - Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + WorkflowRunBackendId string `protobuf:"bytes,1,opt,name=workflow_run_backend_id,json=workflowRunBackendId,proto3" json:"workflow_run_backend_id,omitempty"` + WorkflowJobRunBackendId string `protobuf:"bytes,2,opt,name=workflow_job_run_backend_id,json=workflowJobRunBackendId,proto3" json:"workflow_job_run_backend_id,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetSignedArtifactURLRequest) Reset() { *x = GetSignedArtifactURLRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_artifact_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_artifact_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *GetSignedArtifactURLRequest) String() string { @@ -526,7 +511,7 @@ func (*GetSignedArtifactURLRequest) ProtoMessage() {} func (x *GetSignedArtifactURLRequest) ProtoReflect() protoreflect.Message { mi := &file_artifact_proto_msgTypes[7] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -563,20 +548,17 @@ func (x *GetSignedArtifactURLRequest) GetName() string { } type GetSignedArtifactURLResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + SignedUrl string `protobuf:"bytes,1,opt,name=signed_url,json=signedUrl,proto3" json:"signed_url,omitempty"` unknownFields protoimpl.UnknownFields - - SignedUrl string `protobuf:"bytes,1,opt,name=signed_url,json=signedUrl,proto3" json:"signed_url,omitempty"` + sizeCache protoimpl.SizeCache } func (x *GetSignedArtifactURLResponse) Reset() { *x = GetSignedArtifactURLResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_artifact_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_artifact_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *GetSignedArtifactURLResponse) String() string { @@ -587,7 +569,7 @@ func (*GetSignedArtifactURLResponse) ProtoMessage() {} func (x *GetSignedArtifactURLResponse) ProtoReflect() protoreflect.Message { mi := &file_artifact_proto_msgTypes[8] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -610,22 +592,19 @@ func (x *GetSignedArtifactURLResponse) GetSignedUrl() string { } type DeleteArtifactRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - WorkflowRunBackendId string `protobuf:"bytes,1,opt,name=workflow_run_backend_id,json=workflowRunBackendId,proto3" json:"workflow_run_backend_id,omitempty"` - WorkflowJobRunBackendId string `protobuf:"bytes,2,opt,name=workflow_job_run_backend_id,json=workflowJobRunBackendId,proto3" json:"workflow_job_run_backend_id,omitempty"` - Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + WorkflowRunBackendId string `protobuf:"bytes,1,opt,name=workflow_run_backend_id,json=workflowRunBackendId,proto3" json:"workflow_run_backend_id,omitempty"` + WorkflowJobRunBackendId string `protobuf:"bytes,2,opt,name=workflow_job_run_backend_id,json=workflowJobRunBackendId,proto3" json:"workflow_job_run_backend_id,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DeleteArtifactRequest) Reset() { *x = DeleteArtifactRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_artifact_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_artifact_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *DeleteArtifactRequest) String() string { @@ -636,7 +615,7 @@ func (*DeleteArtifactRequest) ProtoMessage() {} func (x *DeleteArtifactRequest) ProtoReflect() protoreflect.Message { mi := &file_artifact_proto_msgTypes[9] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -673,21 +652,18 @@ func (x *DeleteArtifactRequest) GetName() string { } type DeleteArtifactResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + ArtifactId int64 `protobuf:"varint,2,opt,name=artifact_id,json=artifactId,proto3" json:"artifact_id,omitempty"` unknownFields protoimpl.UnknownFields - - Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` - ArtifactId int64 `protobuf:"varint,2,opt,name=artifact_id,json=artifactId,proto3" json:"artifact_id,omitempty"` + sizeCache protoimpl.SizeCache } func (x *DeleteArtifactResponse) Reset() { *x = DeleteArtifactResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_artifact_proto_msgTypes[10] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } + mi := &file_artifact_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } func (x *DeleteArtifactResponse) String() string { @@ -698,7 +674,7 @@ func (*DeleteArtifactResponse) ProtoMessage() {} func (x *DeleteArtifactResponse) ProtoReflect() protoreflect.Message { mi := &file_artifact_proto_msgTypes[10] - if protoimpl.UnsafeEnabled && x != nil { + if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) @@ -729,173 +705,105 @@ func (x *DeleteArtifactResponse) GetArtifactId() int64 { var File_artifact_proto protoreflect.FileDescriptor -var file_artifact_proto_rawDesc = []byte{ - 0x0a, 0x0e, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x12, 0x1d, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, - 0x2e, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x1a, - 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, - 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x1a, 0x1e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, - 0x66, 0x2f, 0x77, 0x72, 0x61, 0x70, 0x70, 0x65, 0x72, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x22, 0xf5, 0x01, 0x0a, 0x15, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x41, 0x72, 0x74, 0x69, 0x66, - 0x61, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x35, 0x0a, 0x17, 0x77, 0x6f, - 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, - 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x14, 0x77, 0x6f, 0x72, - 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x49, - 0x64, 0x12, 0x3c, 0x0a, 0x1b, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x6a, 0x6f, - 0x62, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x5f, 0x69, 0x64, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x17, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, - 0x4a, 0x6f, 0x62, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x49, 0x64, 0x12, - 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, - 0x61, 0x6d, 0x65, 0x12, 0x39, 0x0a, 0x0a, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x5f, 0x61, - 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, - 0x61, 0x6d, 0x70, 0x52, 0x09, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x12, 0x18, - 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x52, - 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x54, 0x0a, 0x16, 0x43, 0x72, 0x65, 0x61, - 0x74, 0x65, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x02, - 0x6f, 0x6b, 0x12, 0x2a, 0x0a, 0x11, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x5f, 0x75, 0x70, 0x6c, - 0x6f, 0x61, 0x64, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x73, - 0x69, 0x67, 0x6e, 0x65, 0x64, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x55, 0x72, 0x6c, 0x22, 0xe8, - 0x01, 0x0a, 0x17, 0x46, 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x41, 0x72, 0x74, 0x69, 0x66, - 0x61, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x35, 0x0a, 0x17, 0x77, 0x6f, - 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, - 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x14, 0x77, 0x6f, 0x72, - 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x49, - 0x64, 0x12, 0x3c, 0x0a, 0x1b, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x6a, 0x6f, - 0x62, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x5f, 0x69, 0x64, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x17, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, - 0x4a, 0x6f, 0x62, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x49, 0x64, 0x12, - 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, - 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x30, 0x0a, 0x04, 0x68, 0x61, 0x73, 0x68, 0x18, - 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x61, - 0x6c, 0x75, 0x65, 0x52, 0x04, 0x68, 0x61, 0x73, 0x68, 0x22, 0x4b, 0x0a, 0x18, 0x46, 0x69, 0x6e, - 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x08, 0x52, 0x02, 0x6f, 0x6b, 0x12, 0x1f, 0x0a, 0x0b, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, - 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x61, 0x72, 0x74, 0x69, - 0x66, 0x61, 0x63, 0x74, 0x49, 0x64, 0x22, 0x84, 0x02, 0x0a, 0x14, 0x4c, 0x69, 0x73, 0x74, 0x41, - 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, - 0x35, 0x0a, 0x17, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x75, 0x6e, 0x5f, - 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x14, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, - 0x6b, 0x65, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x3c, 0x0a, 0x1b, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, - 0x6f, 0x77, 0x5f, 0x6a, 0x6f, 0x62, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, - 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x17, 0x77, 0x6f, 0x72, - 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x4a, 0x6f, 0x62, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, 0x6b, 0x65, - 0x6e, 0x64, 0x49, 0x64, 0x12, 0x3d, 0x0a, 0x0b, 0x6e, 0x61, 0x6d, 0x65, 0x5f, 0x66, 0x69, 0x6c, - 0x74, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x67, 0x6f, 0x6f, 0x67, - 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x69, - 0x6e, 0x67, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x46, 0x69, 0x6c, - 0x74, 0x65, 0x72, 0x12, 0x38, 0x0a, 0x09, 0x69, 0x64, 0x5f, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x49, 0x6e, 0x74, 0x36, 0x34, 0x56, 0x61, - 0x6c, 0x75, 0x65, 0x52, 0x08, 0x69, 0x64, 0x46, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x22, 0x7c, 0x0a, - 0x15, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x73, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a, 0x09, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, - 0x63, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x45, 0x2e, 0x67, 0x69, 0x74, 0x68, - 0x75, 0x62, 0x2e, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x72, 0x65, 0x73, 0x75, 0x6c, - 0x74, 0x73, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x72, - 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, - 0x4d, 0x6f, 0x6e, 0x6f, 0x6c, 0x69, 0x74, 0x68, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, - 0x52, 0x09, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x73, 0x22, 0xa1, 0x02, 0x0a, 0x26, - 0x4c, 0x69, 0x73, 0x74, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, 0x4d, 0x6f, 0x6e, 0x6f, 0x6c, 0x69, 0x74, 0x68, 0x41, 0x72, - 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x12, 0x35, 0x0a, 0x17, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, - 0x6f, 0x77, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x5f, 0x69, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x14, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, - 0x77, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x3c, 0x0a, - 0x1b, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x6a, 0x6f, 0x62, 0x5f, 0x72, 0x75, - 0x6e, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x17, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x4a, 0x6f, 0x62, 0x52, - 0x75, 0x6e, 0x42, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x64, - 0x61, 0x74, 0x61, 0x62, 0x61, 0x73, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, - 0x52, 0x0a, 0x64, 0x61, 0x74, 0x61, 0x62, 0x61, 0x73, 0x65, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, - 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, - 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, - 0x73, 0x69, 0x7a, 0x65, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, - 0x61, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, - 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, - 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x22, - 0xa6, 0x01, 0x0a, 0x1b, 0x47, 0x65, 0x74, 0x53, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x41, 0x72, 0x74, - 0x69, 0x66, 0x61, 0x63, 0x74, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, - 0x35, 0x0a, 0x17, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x75, 0x6e, 0x5f, - 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x14, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, - 0x6b, 0x65, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x3c, 0x0a, 0x1b, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, - 0x6f, 0x77, 0x5f, 0x6a, 0x6f, 0x62, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, - 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x17, 0x77, 0x6f, 0x72, - 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x4a, 0x6f, 0x62, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, 0x6b, 0x65, - 0x6e, 0x64, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x3d, 0x0a, 0x1c, 0x47, 0x65, 0x74, 0x53, - 0x69, 0x67, 0x6e, 0x65, 0x64, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x55, 0x52, 0x4c, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x69, 0x67, 0x6e, - 0x65, 0x64, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x69, - 0x67, 0x6e, 0x65, 0x64, 0x55, 0x72, 0x6c, 0x22, 0xa0, 0x01, 0x0a, 0x15, 0x44, 0x65, 0x6c, 0x65, - 0x74, 0x65, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x12, 0x35, 0x0a, 0x17, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x75, - 0x6e, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x14, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x52, 0x75, 0x6e, 0x42, - 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x3c, 0x0a, 0x1b, 0x77, 0x6f, 0x72, 0x6b, - 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x6a, 0x6f, 0x62, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x62, 0x61, 0x63, - 0x6b, 0x65, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x17, 0x77, - 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x4a, 0x6f, 0x62, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, - 0x6b, 0x65, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x49, 0x0a, 0x16, 0x44, 0x65, - 0x6c, 0x65, 0x74, 0x65, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x02, 0x6f, 0x6b, 0x12, 0x1f, 0x0a, 0x0b, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, - 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x61, 0x72, 0x74, 0x69, 0x66, - 0x61, 0x63, 0x74, 0x49, 0x64, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -} +const file_artifact_proto_rawDesc = "" + + "\n" + + "\x0eartifact.proto\x12\x1dgithub.actions.results.api.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/wrappers.proto\"\xb0\x02\n" + + "\x15CreateArtifactRequest\x125\n" + + "\x17workflow_run_backend_id\x18\x01 \x01(\tR\x14workflowRunBackendId\x12<\n" + + "\x1bworkflow_job_run_backend_id\x18\x02 \x01(\tR\x17workflowJobRunBackendId\x12\x12\n" + + "\x04name\x18\x03 \x01(\tR\x04name\x129\n" + + "\n" + + "expires_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\x12\x18\n" + + "\aversion\x18\x05 \x01(\x05R\aversion\x129\n" + + "\tmime_type\x18\x06 \x01(\v2\x1c.google.protobuf.StringValueR\bmimeType\"T\n" + + "\x16CreateArtifactResponse\x12\x0e\n" + + "\x02ok\x18\x01 \x01(\bR\x02ok\x12*\n" + + "\x11signed_upload_url\x18\x02 \x01(\tR\x0fsignedUploadUrl\"\xe8\x01\n" + + "\x17FinalizeArtifactRequest\x125\n" + + "\x17workflow_run_backend_id\x18\x01 \x01(\tR\x14workflowRunBackendId\x12<\n" + + "\x1bworkflow_job_run_backend_id\x18\x02 \x01(\tR\x17workflowJobRunBackendId\x12\x12\n" + + "\x04name\x18\x03 \x01(\tR\x04name\x12\x12\n" + + "\x04size\x18\x04 \x01(\x03R\x04size\x120\n" + + "\x04hash\x18\x05 \x01(\v2\x1c.google.protobuf.StringValueR\x04hash\"K\n" + + "\x18FinalizeArtifactResponse\x12\x0e\n" + + "\x02ok\x18\x01 \x01(\bR\x02ok\x12\x1f\n" + + "\vartifact_id\x18\x02 \x01(\x03R\n" + + "artifactId\"\x84\x02\n" + + "\x14ListArtifactsRequest\x125\n" + + "\x17workflow_run_backend_id\x18\x01 \x01(\tR\x14workflowRunBackendId\x12<\n" + + "\x1bworkflow_job_run_backend_id\x18\x02 \x01(\tR\x17workflowJobRunBackendId\x12=\n" + + "\vname_filter\x18\x03 \x01(\v2\x1c.google.protobuf.StringValueR\n" + + "nameFilter\x128\n" + + "\tid_filter\x18\x04 \x01(\v2\x1b.google.protobuf.Int64ValueR\bidFilter\"|\n" + + "\x15ListArtifactsResponse\x12c\n" + + "\tartifacts\x18\x01 \x03(\v2E.github.actions.results.api.v1.ListArtifactsResponse_MonolithArtifactR\tartifacts\"\xa1\x02\n" + + "&ListArtifactsResponse_MonolithArtifact\x125\n" + + "\x17workflow_run_backend_id\x18\x01 \x01(\tR\x14workflowRunBackendId\x12<\n" + + "\x1bworkflow_job_run_backend_id\x18\x02 \x01(\tR\x17workflowJobRunBackendId\x12\x1f\n" + + "\vdatabase_id\x18\x03 \x01(\x03R\n" + + "databaseId\x12\x12\n" + + "\x04name\x18\x04 \x01(\tR\x04name\x12\x12\n" + + "\x04size\x18\x05 \x01(\x03R\x04size\x129\n" + + "\n" + + "created_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\"\xa6\x01\n" + + "\x1bGetSignedArtifactURLRequest\x125\n" + + "\x17workflow_run_backend_id\x18\x01 \x01(\tR\x14workflowRunBackendId\x12<\n" + + "\x1bworkflow_job_run_backend_id\x18\x02 \x01(\tR\x17workflowJobRunBackendId\x12\x12\n" + + "\x04name\x18\x03 \x01(\tR\x04name\"=\n" + + "\x1cGetSignedArtifactURLResponse\x12\x1d\n" + + "\n" + + "signed_url\x18\x01 \x01(\tR\tsignedUrl\"\xa0\x01\n" + + "\x15DeleteArtifactRequest\x125\n" + + "\x17workflow_run_backend_id\x18\x01 \x01(\tR\x14workflowRunBackendId\x12<\n" + + "\x1bworkflow_job_run_backend_id\x18\x02 \x01(\tR\x17workflowJobRunBackendId\x12\x12\n" + + "\x04name\x18\x03 \x01(\tR\x04name\"I\n" + + "\x16DeleteArtifactResponse\x12\x0e\n" + + "\x02ok\x18\x01 \x01(\bR\x02ok\x12\x1f\n" + + "\vartifact_id\x18\x02 \x01(\x03R\n" + + "artifactIdB)Z'code.gitea.io/gitea/routers/api/actionsb\x06proto3" var ( file_artifact_proto_rawDescOnce sync.Once - file_artifact_proto_rawDescData = file_artifact_proto_rawDesc + file_artifact_proto_rawDescData []byte ) func file_artifact_proto_rawDescGZIP() []byte { file_artifact_proto_rawDescOnce.Do(func() { - file_artifact_proto_rawDescData = protoimpl.X.CompressGZIP(file_artifact_proto_rawDescData) + file_artifact_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_artifact_proto_rawDesc), len(file_artifact_proto_rawDesc))) }) return file_artifact_proto_rawDescData } -var ( - file_artifact_proto_msgTypes = make([]protoimpl.MessageInfo, 11) - file_artifact_proto_goTypes = []interface{}{ - (*CreateArtifactRequest)(nil), // 0: github.actions.results.api.v1.CreateArtifactRequest - (*CreateArtifactResponse)(nil), // 1: github.actions.results.api.v1.CreateArtifactResponse - (*FinalizeArtifactRequest)(nil), // 2: github.actions.results.api.v1.FinalizeArtifactRequest - (*FinalizeArtifactResponse)(nil), // 3: github.actions.results.api.v1.FinalizeArtifactResponse - (*ListArtifactsRequest)(nil), // 4: github.actions.results.api.v1.ListArtifactsRequest - (*ListArtifactsResponse)(nil), // 5: github.actions.results.api.v1.ListArtifactsResponse - (*ListArtifactsResponse_MonolithArtifact)(nil), // 6: github.actions.results.api.v1.ListArtifactsResponse_MonolithArtifact - (*GetSignedArtifactURLRequest)(nil), // 7: github.actions.results.api.v1.GetSignedArtifactURLRequest - (*GetSignedArtifactURLResponse)(nil), // 8: github.actions.results.api.v1.GetSignedArtifactURLResponse - (*DeleteArtifactRequest)(nil), // 9: github.actions.results.api.v1.DeleteArtifactRequest - (*DeleteArtifactResponse)(nil), // 10: github.actions.results.api.v1.DeleteArtifactResponse - (*timestamppb.Timestamp)(nil), // 11: google.protobuf.Timestamp - (*wrapperspb.StringValue)(nil), // 12: google.protobuf.StringValue - (*wrapperspb.Int64Value)(nil), // 13: google.protobuf.Int64Value - } -) - +var file_artifact_proto_msgTypes = make([]protoimpl.MessageInfo, 11) +var file_artifact_proto_goTypes = []any{ + (*CreateArtifactRequest)(nil), // 0: github.actions.results.api.v1.CreateArtifactRequest + (*CreateArtifactResponse)(nil), // 1: github.actions.results.api.v1.CreateArtifactResponse + (*FinalizeArtifactRequest)(nil), // 2: github.actions.results.api.v1.FinalizeArtifactRequest + (*FinalizeArtifactResponse)(nil), // 3: github.actions.results.api.v1.FinalizeArtifactResponse + (*ListArtifactsRequest)(nil), // 4: github.actions.results.api.v1.ListArtifactsRequest + (*ListArtifactsResponse)(nil), // 5: github.actions.results.api.v1.ListArtifactsResponse + (*ListArtifactsResponse_MonolithArtifact)(nil), // 6: github.actions.results.api.v1.ListArtifactsResponse_MonolithArtifact + (*GetSignedArtifactURLRequest)(nil), // 7: github.actions.results.api.v1.GetSignedArtifactURLRequest + (*GetSignedArtifactURLResponse)(nil), // 8: github.actions.results.api.v1.GetSignedArtifactURLResponse + (*DeleteArtifactRequest)(nil), // 9: github.actions.results.api.v1.DeleteArtifactRequest + (*DeleteArtifactResponse)(nil), // 10: github.actions.results.api.v1.DeleteArtifactResponse + (*timestamppb.Timestamp)(nil), // 11: google.protobuf.Timestamp + (*wrapperspb.StringValue)(nil), // 12: google.protobuf.StringValue + (*wrapperspb.Int64Value)(nil), // 13: google.protobuf.Int64Value +} var file_artifact_proto_depIdxs = []int32{ 11, // 0: github.actions.results.api.v1.CreateArtifactRequest.expires_at:type_name -> google.protobuf.Timestamp - 12, // 1: github.actions.results.api.v1.FinalizeArtifactRequest.hash:type_name -> google.protobuf.StringValue - 12, // 2: github.actions.results.api.v1.ListArtifactsRequest.name_filter:type_name -> google.protobuf.StringValue - 13, // 3: github.actions.results.api.v1.ListArtifactsRequest.id_filter:type_name -> google.protobuf.Int64Value - 6, // 4: github.actions.results.api.v1.ListArtifactsResponse.artifacts:type_name -> github.actions.results.api.v1.ListArtifactsResponse_MonolithArtifact - 11, // 5: github.actions.results.api.v1.ListArtifactsResponse_MonolithArtifact.created_at:type_name -> google.protobuf.Timestamp - 6, // [6:6] is the sub-list for method output_type - 6, // [6:6] is the sub-list for method input_type - 6, // [6:6] is the sub-list for extension type_name - 6, // [6:6] is the sub-list for extension extendee - 0, // [0:6] is the sub-list for field type_name + 12, // 1: github.actions.results.api.v1.CreateArtifactRequest.mime_type:type_name -> google.protobuf.StringValue + 12, // 2: github.actions.results.api.v1.FinalizeArtifactRequest.hash:type_name -> google.protobuf.StringValue + 12, // 3: github.actions.results.api.v1.ListArtifactsRequest.name_filter:type_name -> google.protobuf.StringValue + 13, // 4: github.actions.results.api.v1.ListArtifactsRequest.id_filter:type_name -> google.protobuf.Int64Value + 6, // 5: github.actions.results.api.v1.ListArtifactsResponse.artifacts:type_name -> github.actions.results.api.v1.ListArtifactsResponse_MonolithArtifact + 11, // 6: github.actions.results.api.v1.ListArtifactsResponse_MonolithArtifact.created_at:type_name -> google.protobuf.Timestamp + 7, // [7:7] is the sub-list for method output_type + 7, // [7:7] is the sub-list for method input_type + 7, // [7:7] is the sub-list for extension type_name + 7, // [7:7] is the sub-list for extension extendee + 0, // [0:7] is the sub-list for field type_name } func init() { file_artifact_proto_init() } @@ -903,145 +811,11 @@ func file_artifact_proto_init() { if File_artifact_proto != nil { return } - if !protoimpl.UnsafeEnabled { - file_artifact_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*CreateArtifactRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_artifact_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*CreateArtifactResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_artifact_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*FinalizeArtifactRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_artifact_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*FinalizeArtifactResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_artifact_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ListArtifactsRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_artifact_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ListArtifactsResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_artifact_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ListArtifactsResponse_MonolithArtifact); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_artifact_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*GetSignedArtifactURLRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_artifact_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*GetSignedArtifactURLResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_artifact_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DeleteArtifactRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_artifact_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DeleteArtifactResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_artifact_proto_rawDesc, + RawDescriptor: unsafe.Slice(unsafe.StringData(file_artifact_proto_rawDesc), len(file_artifact_proto_rawDesc)), NumEnums: 0, NumMessages: 11, NumExtensions: 0, @@ -1052,7 +826,6 @@ func file_artifact_proto_init() { MessageInfos: file_artifact_proto_msgTypes, }.Build() File_artifact_proto = out.File - file_artifact_proto_rawDesc = nil file_artifact_proto_goTypes = nil file_artifact_proto_depIdxs = nil } diff --git a/routers/api/actions/artifact.proto b/routers/api/actions/artifact.proto index c68e5d030d09c..7da8bad564b8e 100644 --- a/routers/api/actions/artifact.proto +++ b/routers/api/actions/artifact.proto @@ -5,12 +5,15 @@ import "google/protobuf/wrappers.proto"; package github.actions.results.api.v1; +option go_package = "code.gitea.io/gitea/routers/api/actions"; + 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; + google.protobuf.StringValue mime_type = 6; } message CreateArtifactResponse { diff --git a/routers/api/actions/artifacts_chunks.go b/routers/api/actions/artifacts_chunks.go index 708931d1ac1c8..2a64769252345 100644 --- a/routers/api/actions/artifacts_chunks.go +++ b/routers/api/actions/artifacts_chunks.go @@ -126,10 +126,13 @@ func listChunksByRunID(st storage.ObjectStorage, runID int64) (map[int64][]*chun 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 + var chunkMap map[string]*chunkFileItem + if blist != nil { + 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) @@ -144,28 +147,33 @@ func listChunksByRunIDV4(st storage.ObjectStorage, runID, artifactID int64, blis 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) + rchunkName, err := base64.RawURLEncoding.DecodeString(b64chunkName) if err != nil { return fmt.Errorf("failed to parse chunkName: %v", err) } chunkName := string(rchunkName) item.End = item.Start + size - 1 + // Single chunk upload with blockid if _, ok := chunkMap[chunkName]; ok { chunkMap[chunkName] = &item + } else if chunks == nil && chunkMap == nil { + chunks = []*chunkFileItem{&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 + if blist != nil { + 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 diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index 6d27479628590..9bf09496cc4dc 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -94,6 +94,7 @@ import ( "io" "net/http" "net/url" + "os" "strconv" "strings" "time" @@ -107,6 +108,7 @@ import ( "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/context" + "xorm.io/builder" "google.golang.org/protobuf/encoding/protojson" protoreflect "google.golang.org/protobuf/reflect/protoreflect" @@ -170,7 +172,7 @@ func (r artifactV4Routes) buildSignature(endp, expires, artifactName string, tas 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) + "/" + endp + "?sig=" + base64.RawURLEncoding.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 } @@ -180,7 +182,7 @@ func (r artifactV4Routes) verifySignature(ctx *ArtifactContext, endp string) (*a 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) + dsig, _ := base64.RawURLEncoding.DecodeString(sig) taskID, _ := strconv.ParseInt(rawTaskID, 10, 64) artifactID, _ := strconv.ParseInt(rawArtifactID, 10, 64) @@ -217,7 +219,7 @@ func (r artifactV4Routes) verifySignature(ctx *ArtifactContext, endp string) (*a 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) + has, err := db.GetEngine(ctx).Where(builder.Eq{"run_id": runID, "artifact_name": name}, builder.Like{"content_encoding", "/"}).Get(&art) if err != nil { return nil, err } else if !has { @@ -271,14 +273,20 @@ func (r *artifactV4Routes) createArtifact(ctx *ArtifactContext) { if req.ExpiresAt != nil { rententionDays = int64(time.Until(req.ExpiresAt.AsTime()).Hours() / 24) } + encoding := req.GetMimeType().GetValue() + fileName := artifactName + if !strings.Contains(encoding, "/") || req.GetVersion() < 7 { + encoding = ArtifactV4ContentEncoding + fileName = artifactName + ".zip" + } // create or get artifact with name and path - artifact, err := actions.CreateArtifact(ctx, ctx.ActionTask, artifactName, artifactName+".zip", rententionDays) + artifact, err := actions.CreateArtifact(ctx, ctx.ActionTask, artifactName, fileName, 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.ContentEncoding = encoding artifact.FileSize = 0 artifact.FileCompressedSize = 0 if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil { @@ -327,7 +335,7 @@ func (r *artifactV4Routes) uploadArtifact(ctx *ArtifactContext) { 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) + _, err := r.fs.Save(fmt.Sprintf("tmpv4%d/block-%d-%d-%s", task.Job.RunID, task.Job.RunID, ctx.Req.ContentLength, base64.RawURLEncoding.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") @@ -398,12 +406,16 @@ func (r *artifactV4Routes) finalizeArtifact(ctx *ArtifactContext) { 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 + if os.IsNotExist(err) { + chunks, err = listChunksByRunIDV4(r.fs, runID, artifact.ID, nil) + } else { + if err != nil { + log.Error("Error merge chunks: %v", err) + ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks") + return + } + chunks, ok = chunkMap[artifact.ID] } - chunks, ok = chunkMap[artifact.ID] if !ok { log.Error("Error merge chunks") ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks") @@ -449,8 +461,9 @@ func (r *artifactV4Routes) listArtifacts(ctx *ArtifactContext) { } artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{ - RunID: runID, - Status: int(actions.ArtifactStatusUploadConfirmed), + RunID: runID, + Status: int(actions.ArtifactStatusUploadConfirmed), + FinalizedArtifactsV4: true, }) if err != nil { log.Error("Error getting artifacts: %v", err) @@ -462,7 +475,7 @@ func (r *artifactV4Routes) listArtifacts(ctx *ArtifactContext) { 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 { + if _, ok := table[artifact.ArtifactName]; ok || req.IdFilter != nil && artifact.ID != req.IdFilter.Value || req.NameFilter != nil && artifact.ArtifactName != req.NameFilter.Value { table[artifact.ArtifactName] = nil continue } diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 33c1e73aa43e1..3ab4f1de37716 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -799,9 +799,8 @@ func ArtifactsDownloadView(ctx *context_module.Context) { } } - ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip; filename*=UTF-8''%s.zip", url.PathEscape(artifactName), artifactName)) - if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) { + ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%s; filename*=UTF-8''%s", url.PathEscape(artifacts[0].ArtifactPath), artifacts[0].ArtifactPath)) err := actions.DownloadArtifactV4(ctx.Base, artifacts[0]) if err != nil { ctx.ServerError("DownloadArtifactV4", err) @@ -810,6 +809,8 @@ func ArtifactsDownloadView(ctx *context_module.Context) { return } + ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip; filename*=UTF-8''%s.zip", url.PathEscape(artifactName), artifactName)) + // Artifacts using the v1-v3 backend are stored as multiple individual files per artifact on the backend // Those need to be zipped for download writer := zip.NewWriter(ctx.Resp) From 36b0c0e5c6b26d8d7ebb62fd8d623a5180561b7b Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sat, 28 Feb 2026 23:13:23 +0100 Subject: [PATCH 02/78] fix typo --- routers/api/actions/artifactsv4.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index f56f2bfca0453..c723c4f98dfef 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -280,7 +280,7 @@ func (r *artifactV4Routes) createArtifact(ctx *ArtifactContext) { fileName = artifactName + ".zip" } // create or get artifact with name and path - artifact, err := actions.CreateArtifact(ctx, ctx.ActionTask, artifactName, fileName, rententionDays) + artifact, err := actions.CreateArtifact(ctx, ctx.ActionTask, artifactName, fileName, retentionDays) if err != nil { log.Error("Error create or get artifact: %v", err) ctx.HTTPError(http.StatusInternalServerError, "Error create or get artifact") From 1fd4fbeda508c48c118473c6449037fb41b77f19 Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sat, 28 Feb 2026 23:28:08 +0100 Subject: [PATCH 03/78] . --- routers/api/actions/artifactsv4.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index c723c4f98dfef..846d1211a94a3 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -406,18 +406,18 @@ func (r *artifactV4Routes) finalizeArtifact(ctx *ArtifactContext) { if err != nil { log.Warn("Failed to read BlockList, fallback to old behavior: %v", err) chunkMap, err := listChunksByRunID(r.fs, runID) - if os.IsNotExist(err) { - chunks, err = listChunksByRunIDV4(r.fs, runID, artifact.ID, nil) - } else { - if err != nil { - log.Error("Error merge chunks: %v", err) + if err == nil { + chunks, ok = chunkMap[artifact.ID] + if !ok { + log.Error("Error merge chunks") ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks") return } - chunks, ok = chunkMap[artifact.ID] + } else if os.IsNotExist(err) { + chunks, err = listChunksByRunIDV4(r.fs, runID, artifact.ID, nil) } - if !ok { - log.Error("Error merge chunks") + if err != nil { + log.Error("Error merge chunks: %v", err) ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks") return } From 4b1fa714b95e1f88ed3121908e344433945d44b6 Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sat, 28 Feb 2026 23:32:27 +0100 Subject: [PATCH 04/78] . --- routers/api/actions/artifactsv4.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index 846d1211a94a3..9a651537f4279 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -108,11 +108,11 @@ import ( "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/context" - "xorm.io/builder" "google.golang.org/protobuf/encoding/protojson" protoreflect "google.golang.org/protobuf/reflect/protoreflect" "google.golang.org/protobuf/types/known/timestamppb" + "xorm.io/builder" ) const ( From ff5d1bb5976dc83295e72689650439a605d0b6ab Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sat, 28 Feb 2026 23:37:12 +0100 Subject: [PATCH 05/78] revert b64chunkName encoding, seams like I caused the initial issue in the code and fixed it later by changing the other end --- routers/api/actions/artifacts_chunks.go | 2 +- routers/api/actions/artifactsv4.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/routers/api/actions/artifacts_chunks.go b/routers/api/actions/artifacts_chunks.go index 2a64769252345..12ba2124a49d2 100644 --- a/routers/api/actions/artifacts_chunks.go +++ b/routers/api/actions/artifacts_chunks.go @@ -147,7 +147,7 @@ func listChunksByRunIDV4(st storage.ObjectStorage, runID, artifactID int64, blis 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.RawURLEncoding.DecodeString(b64chunkName) + rchunkName, err := base64.URLEncoding.DecodeString(b64chunkName) if err != nil { return fmt.Errorf("failed to parse chunkName: %v", err) } diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index 9a651537f4279..91e6974487c2a 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -335,7 +335,7 @@ func (r *artifactV4Routes) uploadArtifact(ctx *ArtifactContext) { return } } else { - _, err := r.fs.Save(fmt.Sprintf("tmpv4%d/block-%d-%d-%s", task.Job.RunID, task.Job.RunID, ctx.Req.ContentLength, base64.RawURLEncoding.EncodeToString([]byte(blockid))), ctx.Req.Body, -1) + _, 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") From c6540ef9b30e7aa0c1efc312d2a88b1a3a836505 Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sun, 1 Mar 2026 00:01:09 +0100 Subject: [PATCH 06/78] add Content-Security-Policy --- routers/web/repo/actions/view.go | 1 + 1 file changed, 1 insertion(+) diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 3ab4f1de37716..3c01459980d1f 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -801,6 +801,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) { if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) { ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%s; filename*=UTF-8''%s", url.PathEscape(artifacts[0].ArtifactPath), artifacts[0].ArtifactPath)) + ctx.Resp.Header().Set("Content-Security-Policy", "sandbox; default-src 'none';") err := actions.DownloadArtifactV4(ctx.Base, artifacts[0]) if err != nil { ctx.ServerError("DownloadArtifactV4", err) From 035aa221bde2731616d76b1d5d57d6d001ee8a53 Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sun, 1 Mar 2026 00:06:19 +0100 Subject: [PATCH 07/78] Revert "add Content-Security-Policy" This reverts commit c6540ef9b30e7aa0c1efc312d2a88b1a3a836505. --- routers/web/repo/actions/view.go | 1 - 1 file changed, 1 deletion(-) diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 3c01459980d1f..3ab4f1de37716 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -801,7 +801,6 @@ func ArtifactsDownloadView(ctx *context_module.Context) { if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) { ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%s; filename*=UTF-8''%s", url.PathEscape(artifacts[0].ArtifactPath), artifacts[0].ArtifactPath)) - ctx.Resp.Header().Set("Content-Security-Policy", "sandbox; default-src 'none';") err := actions.DownloadArtifactV4(ctx.Base, artifacts[0]) if err != nil { ctx.ServerError("DownloadArtifactV4", err) From 33a2b68adc3600710f7bd03acabd8aea623a91fc Mon Sep 17 00:00:00 2001 From: ChristopherHX Date: Sun, 1 Mar 2026 00:08:17 +0100 Subject: [PATCH 08/78] Update routers/web/repo/actions/view.go Signed-off-by: ChristopherHX --- routers/web/repo/actions/view.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 3ab4f1de37716..25314d3e5b26d 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -800,7 +800,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) { } if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) { - ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%s; filename*=UTF-8''%s", url.PathEscape(artifacts[0].ArtifactPath), artifacts[0].ArtifactPath)) + ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s; filename*=UTF-8''%s", url.PathEscape(artifacts[0].ArtifactPath), artifacts[0].ArtifactPath)) err := actions.DownloadArtifactV4(ctx.Base, artifacts[0]) if err != nil { ctx.ServerError("DownloadArtifactV4", err) From 2980ea9d6fced8d7ec15d6d4f21071e20634572d Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sun, 1 Mar 2026 00:09:27 +0100 Subject: [PATCH 09/78] update comment --- modules/actions/artifacts.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/actions/artifacts.go b/modules/actions/artifacts.go index 88d3066750b45..d3bd7eb73bcf5 100644 --- a/modules/actions/artifacts.go +++ b/modules/actions/artifacts.go @@ -14,7 +14,7 @@ import ( ) // Artifacts using the v4 backend are stored as a single combined zip file per artifact on the backend -// The v4 backend ensures ContentEncoding is set to "application/zip", which is not the case for the old backend +// The v4 backend ensures ContentEncoding contains a slash (otherwise this uses application/zip instead of the custom mime type), which is not the case for the old backend func IsArtifactV4(art *actions_model.ActionArtifact) bool { return strings.Contains(art.ContentEncoding, "/") } From fef0ca4401b2d78a3825c054b44999bfc49977be Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sun, 1 Mar 2026 11:10:15 +0100 Subject: [PATCH 10/78] implement TODO --- routers/api/actions/artifacts_chunks.go | 16 +++++++++-- routers/api/actions/artifactsv4.go | 36 ++++++------------------- 2 files changed, 22 insertions(+), 30 deletions(-) diff --git a/routers/api/actions/artifacts_chunks.go b/routers/api/actions/artifacts_chunks.go index 12ba2124a49d2..82e7ba682eef2 100644 --- a/routers/api/actions/artifacts_chunks.go +++ b/routers/api/actions/artifacts_chunks.go @@ -156,12 +156,22 @@ func listChunksByRunIDV4(st storage.ObjectStorage, runID, artifactID int64, blis // Single chunk upload with blockid if _, ok := chunkMap[chunkName]; ok { chunkMap[chunkName] = &item - } else if chunks == nil && chunkMap == nil { + } else if chunkMap == nil { + if chunks != nil { + return errors.New("blockmap is required for chunks > 1") + } chunks = []*chunkFileItem{&item} } return nil - }); err != nil { + }); err != nil && (chunks != nil || blist != nil) { return nil, err + } else if blist == nil && chunks == nil { + var chunkMap map[int64][]*chunkFileItem + chunkMap, err = listChunksByRunID(st, runID) + if err != nil { + return nil, err + } + chunks, _ = chunkMap[artifactID] } if blist != nil { for i, name := range blist.Latest { @@ -175,6 +185,8 @@ func listChunksByRunIDV4(st storage.ObjectStorage, runID, artifactID int64, blis chunk.End += chunk.Start } } + } else if len(chunks) < 1 { + return nil, errors.New("missing Chunk (no block map)") } return chunks, nil } diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index 91e6974487c2a..aa55eeab0180f 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -90,11 +90,11 @@ import ( "crypto/sha256" "encoding/base64" "encoding/xml" + "errors" "fmt" "io" "net/http" "net/url" - "os" "strconv" "strings" "time" @@ -402,35 +402,15 @@ func (r *artifactV4Routes) finalizeArtifact(ctx *ArtifactContext) { } var chunks []*chunkFileItem - blockList, err := r.readBlockList(runID, artifact.ID) + blockList, blockListErr := r.readBlockList(runID, artifact.ID) + chunks, err = listChunksByRunIDV4(r.fs, runID, artifact.ID, blockList) if err != nil { - log.Warn("Failed to read BlockList, fallback to old behavior: %v", err) - chunkMap, err := listChunksByRunID(r.fs, runID) - if err == nil { - chunks, ok = chunkMap[artifact.ID] - if !ok { - log.Error("Error merge chunks") - ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks") - return - } - } else if os.IsNotExist(err) { - chunks, err = listChunksByRunIDV4(r.fs, runID, artifact.ID, nil) - } - if err != nil { - log.Error("Error merge chunks: %v", err) - 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 + log.Error("Error merge chunks: %v", errors.Join(blockListErr, 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 { From 076ee4ffe2bcc67816dd3debac501ba8b7b9b8f1 Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sun, 1 Mar 2026 12:13:51 +0100 Subject: [PATCH 11/78] add more upload tests without content-length header --- routers/api/actions/artifacts_chunks.go | 33 ++++- routers/api/actions/artifactsv4.go | 35 +++-- .../api_actions_artifact_v4_test.go | 130 +++++++++++++----- 3 files changed, 142 insertions(+), 56 deletions(-) diff --git a/routers/api/actions/artifacts_chunks.go b/routers/api/actions/artifacts_chunks.go index 82e7ba682eef2..ce8da5dca1fa4 100644 --- a/routers/api/actions/artifacts_chunks.go +++ b/routers/api/actions/artifacts_chunks.go @@ -21,6 +21,7 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/modules/util" ) func saveUploadChunkBase(st storage.ObjectStorage, ctx *ArtifactContext, @@ -54,7 +55,7 @@ func saveUploadChunkBase(st storage.ObjectStorage, ctx *ArtifactContext, checkErr = errors.New("md5 not match") } } - if writtenSize != contentSize { + if writtenSize != contentSize && contentSize != -1 { checkErr = errors.Join(checkErr, fmt.Errorf("writtenSize %d not match contentSize %d", writtenSize, contentSize)) } if checkErr != nil { @@ -88,7 +89,7 @@ func appendUploadChunk(st storage.ObjectStorage, ctx *ArtifactContext, start, contentSize, runID int64, ) (int64, error) { end := start + contentSize - 1 - return saveUploadChunkBase(st, ctx, artifact, contentSize, runID, start, end, contentSize, false) + return saveUploadChunkBase(st, ctx, artifact, util.Iif(contentSize == 0, -1, contentSize), runID, start, end, contentSize, false) } type chunkFileItem struct { @@ -110,6 +111,13 @@ func listChunksByRunID(st storage.ObjectStorage, runID int64) (map[int64][]*chun 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) } + if (item.End + 1 - item.Start) == 0 { + fi, err := st.Stat(fpath) + if err != nil { + return err + } + item.End = item.Start + fi.Size() - 1 + } chunks = append(chunks, &item) return nil }); err != nil { @@ -144,12 +152,25 @@ func listChunksByRunIDV4(st storage.ObjectStorage, runID, artifactID int64, blis 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) + var fArtifactID int64 + if _, err := fmt.Sscanf(baseName, "block-%d-%d-%d-%s", &item.RunID, &fArtifactID, &size, &b64chunkName); err != nil { + log.Warn("parse content range error: %v", err) + return nil } rchunkName, err := base64.URLEncoding.DecodeString(b64chunkName) if err != nil { - return fmt.Errorf("failed to parse chunkName: %v", err) + log.Warn("failed to parse chunkName: %v", err) + return nil + } + if fArtifactID != artifactID { + return nil + } + if size == 0 { + fi, err := st.Stat(fpath) + if err != nil { + return err + } + size = fi.Size() } chunkName := string(rchunkName) item.End = item.Start + size - 1 @@ -171,7 +192,7 @@ func listChunksByRunIDV4(st storage.ObjectStorage, runID, artifactID int64, blis if err != nil { return nil, err } - chunks, _ = chunkMap[artifactID] + chunks = chunkMap[artifactID] } if blist != nil { for i, name := range blist.Latest { diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index aa55eeab0180f..08a20c3c1014b 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -176,7 +176,7 @@ func (r artifactV4Routes) buildArtifactURL(ctx *ArtifactContext, endp, artifactN return uploadURL } -func (r artifactV4Routes) verifySignature(ctx *ArtifactContext, endp string) (*actions.ActionTask, string, bool) { +func (r artifactV4Routes) verifySignature(ctx *ArtifactContext, endp string) (*actions.ActionTask, int64, bool) { rawTaskID := ctx.Req.URL.Query().Get("taskID") rawArtifactID := ctx.Req.URL.Query().Get("artifactID") sig := ctx.Req.URL.Query().Get("sig") @@ -190,31 +190,42 @@ func (r artifactV4Routes) verifySignature(ctx *ArtifactContext, endp string) (*a if !hmac.Equal(dsig, expecedsig) { log.Error("Error unauthorized") ctx.HTTPError(http.StatusUnauthorized, "Error unauthorized") - return nil, "", false + return nil, 0, 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 + return nil, 0, 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 + return nil, 0, 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 + return nil, 0, 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 nil, 0, false } - return task, artifactName, true + return task, artifactID, true +} + +func (r *artifactV4Routes) getArtifactByID(ctx *ArtifactContext, runID int64, id int64) (*actions.ActionArtifact, error) { + var art actions.ActionArtifact + has, err := db.GetEngine(ctx).Where(builder.Eq{"run_id": runID, "id": id}, builder.Like{"content_encoding", "/"}).Get(&art) + if err != nil { + return nil, err + } else if !has { + return nil, util.ErrNotExist + } + return &art, nil } func (r *artifactV4Routes) getArtifactByName(ctx *ArtifactContext, runID int64, name string) (*actions.ActionArtifact, error) { @@ -303,7 +314,7 @@ func (r *artifactV4Routes) createArtifact(ctx *ArtifactContext) { } func (r *artifactV4Routes) uploadArtifact(ctx *ArtifactContext) { - task, artifactName, ok := r.verifySignature(ctx, "UploadArtifact") + task, artifactID, ok := r.verifySignature(ctx, "UploadArtifact") if !ok { return } @@ -314,7 +325,7 @@ func (r *artifactV4Routes) uploadArtifact(ctx *ArtifactContext) { blockid := ctx.Req.URL.Query().Get("blockid") if blockid == "" { // get artifact by name - artifact, err := r.getArtifactByName(ctx, task.Job.RunID, artifactName) + artifact, err := r.getArtifactByID(ctx, task.Job.RunID, artifactID) if err != nil { log.Error("Error artifact not found: %v", err) ctx.HTTPError(http.StatusNotFound, "Error artifact not found") @@ -335,7 +346,7 @@ func (r *artifactV4Routes) uploadArtifact(ctx *ArtifactContext) { 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) + _, err := r.fs.Save(fmt.Sprintf("tmpv4%d/block-%d-%d-%d-%s", task.Job.RunID, task.Job.RunID, artifactID, 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") @@ -522,13 +533,13 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) { } func (r *artifactV4Routes) downloadArtifact(ctx *ArtifactContext) { - task, artifactName, ok := r.verifySignature(ctx, "DownloadArtifact") + task, artifactID, ok := r.verifySignature(ctx, "DownloadArtifact") if !ok { return } // get artifact by name - artifact, err := r.getArtifactByName(ctx, task.Job.RunID, artifactName) + artifact, err := r.getArtifactByID(ctx, task.Job.RunID, artifactID) if err != nil { log.Error("Error artifact not found: %v", err) ctx.HTTPError(http.StatusNotFound, "Error artifact not found") diff --git a/tests/integration/api_actions_artifact_v4_test.go b/tests/integration/api_actions_artifact_v4_test.go index 3db8bbb82e78c..32cf0c0d6362c 100644 --- a/tests/integration/api_actions_artifact_v4_test.go +++ b/tests/integration/api_actions_artifact_v4_test.go @@ -6,6 +6,7 @@ package integration import ( "bytes" "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/xml" "fmt" @@ -22,6 +23,7 @@ import ( "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/storage" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/api/actions" actions_service "code.gitea.io/gitea/services/actions" @@ -45,45 +47,97 @@ func TestActionsArtifactV4UploadSingleFile(t *testing.T) { token, err := actions_service.CreateAuthorizationToken(48, 792, 193) assert.NoError(t, err) - // acquire artifact upload url - req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", toProtoJSON(&actions.CreateArtifactRequest{ - Version: 4, - Name: "artifact", - WorkflowRunBackendId: "792", - WorkflowJobRunBackendId: "193", - })).AddTokenAuth(token) - resp := MakeRequest(t, req, http.StatusOK) - var uploadResp actions.CreateArtifactResponse - protojson.Unmarshal(resp.Body.Bytes(), &uploadResp) - assert.True(t, uploadResp.Ok) - assert.Contains(t, uploadResp.SignedUploadUrl, "/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact") - - // get upload url - idx := strings.Index(uploadResp.SignedUploadUrl, "/twirp/") - url := uploadResp.SignedUploadUrl[idx:] + "&comp=block" - - // upload artifact chunk - body := strings.Repeat("A", 1024) - req = NewRequestWithBody(t, "PUT", url, strings.NewReader(body)) - MakeRequest(t, req, http.StatusCreated) - - t.Logf("Create artifact confirm") - - sha := sha256.Sum256([]byte(body)) + table := []struct { + name string + version int32 + mimeType string + blockId bool + noLength bool + }{ + // { + // name: "artifact", + // version: 4, + // }, + // { + // name: "artifact2", + // version: 7, + // }, + // { + // name: "artifact3.json", + // version: 7, + // mimeType: "application/json", + // }, + // { + // name: "artifact4.json", + // version: 7, + // mimeType: "application/json", + // blockId: true, + // }, + // { + // name: "artifact4.json", + // version: 7, + // mimeType: "application/json", + // blockId: true, + // noLength: true, + // }, + { + name: "artifact4.json", + version: 7, + mimeType: "application/json", + noLength: true, + }, + } - // confirm artifact upload - req = NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/FinalizeArtifact", toProtoJSON(&actions.FinalizeArtifactRequest{ - Name: "artifact", - Size: 1024, - Hash: wrapperspb.String("sha256:" + hex.EncodeToString(sha[:])), - WorkflowRunBackendId: "792", - WorkflowJobRunBackendId: "193", - })). - AddTokenAuth(token) - resp = MakeRequest(t, req, http.StatusOK) - var finalizeResp actions.FinalizeArtifactResponse - protojson.Unmarshal(resp.Body.Bytes(), &finalizeResp) - assert.True(t, finalizeResp.Ok) + for _, entry := range table { + + // acquire artifact upload url + req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", toProtoJSON(&actions.CreateArtifactRequest{ + Version: entry.version, + Name: entry.name, + WorkflowRunBackendId: "792", + WorkflowJobRunBackendId: "193", + MimeType: util.Iif(entry.mimeType != "", wrapperspb.String(entry.mimeType), nil), + })).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var uploadResp actions.CreateArtifactResponse + protojson.Unmarshal(resp.Body.Bytes(), &uploadResp) + assert.True(t, uploadResp.Ok) + assert.Contains(t, uploadResp.SignedUploadUrl, "/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact") + + // get upload url + idx := strings.Index(uploadResp.SignedUploadUrl, "/twirp/") + url := uploadResp.SignedUploadUrl[idx:] + "&comp=block" + if entry.blockId { + url += "&blockid=" + base64.RawURLEncoding.EncodeToString([]byte("SOME_BIG_BLOCK_ID")) + } + + // upload artifact chunk + body := strings.Repeat("A", 1024) + var bodyReader io.Reader = strings.NewReader(body) + if entry.noLength { + bodyReader = io.MultiReader(bodyReader) + } + req = NewRequestWithBody(t, "PUT", url, bodyReader) + MakeRequest(t, req, http.StatusCreated) + + t.Logf("Create artifact confirm") + + sha := sha256.Sum256([]byte(body)) + + // confirm artifact upload + req = NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/FinalizeArtifact", toProtoJSON(&actions.FinalizeArtifactRequest{ + Name: entry.name, + Size: 1024, + Hash: wrapperspb.String("sha256:" + hex.EncodeToString(sha[:])), + WorkflowRunBackendId: "792", + WorkflowJobRunBackendId: "193", + })). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var finalizeResp actions.FinalizeArtifactResponse + protojson.Unmarshal(resp.Body.Bytes(), &finalizeResp) + assert.True(t, finalizeResp.Ok) + } } func TestActionsArtifactV4UploadSingleFileWrongChecksum(t *testing.T) { From b70c1857023d663868f80b56b0c6c822cc5f8671 Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sun, 1 Mar 2026 12:32:19 +0100 Subject: [PATCH 12/78] assert mime type in db --- .../api_actions_artifact_v4_test.go | 65 +++++++++++-------- 1 file changed, 39 insertions(+), 26 deletions(-) diff --git a/tests/integration/api_actions_artifact_v4_test.go b/tests/integration/api_actions_artifact_v4_test.go index 32cf0c0d6362c..dd52d486af712 100644 --- a/tests/integration/api_actions_artifact_v4_test.go +++ b/tests/integration/api_actions_artifact_v4_test.go @@ -16,6 +16,7 @@ import ( "testing" "time" + actions_model "code.gitea.io/gitea/models/actions" auth_model "code.gitea.io/gitea/models/auth" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" @@ -54,36 +55,41 @@ func TestActionsArtifactV4UploadSingleFile(t *testing.T) { blockId bool noLength bool }{ - // { - // name: "artifact", - // version: 4, - // }, - // { - // name: "artifact2", - // version: 7, - // }, - // { - // name: "artifact3.json", - // version: 7, - // mimeType: "application/json", - // }, - // { - // name: "artifact4.json", - // version: 7, - // mimeType: "application/json", - // blockId: true, - // }, - // { - // name: "artifact4.json", - // version: 7, - // mimeType: "application/json", - // blockId: true, - // noLength: true, - // }, + { + name: "artifact", + version: 4, + }, + { + name: "artifact2", + version: 7, + }, + { + name: "artifact3.json", + version: 7, + mimeType: "application/json", + }, { name: "artifact4.json", version: 7, mimeType: "application/json", + blockId: true, + }, + { + name: "artifact5.json", + version: 7, + mimeType: "application/json", + blockId: true, + noLength: true, + }, + { + name: "artifact6.json", + version: 7, + mimeType: "application/json", + noLength: true, + }, + { + name: "artifact7", + version: 4, noLength: true, }, } @@ -137,6 +143,13 @@ func TestActionsArtifactV4UploadSingleFile(t *testing.T) { var finalizeResp actions.FinalizeArtifactResponse protojson.Unmarshal(resp.Body.Bytes(), &finalizeResp) assert.True(t, finalizeResp.Ok) + + artifact := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionArtifact{ID: finalizeResp.ArtifactId}) + if entry.mimeType != "" { + assert.Equal(t, entry.mimeType, artifact.ContentEncoding) + } else { + assert.Equal(t, actions.ArtifactV4ContentEncoding, artifact.ContentEncoding) + } } } From e2c19cfe99e431117941c644f534fd5e581fd49f Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sun, 1 Mar 2026 12:46:54 +0100 Subject: [PATCH 13/78] lint --- routers/api/actions/artifactsv4.go | 2 +- tests/integration/api_actions_artifact_v4_test.go | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index 08a20c3c1014b..d4d5400bb7cbd 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -217,7 +217,7 @@ func (r artifactV4Routes) verifySignature(ctx *ArtifactContext, endp string) (*a return task, artifactID, true } -func (r *artifactV4Routes) getArtifactByID(ctx *ArtifactContext, runID int64, id int64) (*actions.ActionArtifact, error) { +func (r *artifactV4Routes) getArtifactByID(ctx *ArtifactContext, runID, id int64) (*actions.ActionArtifact, error) { var art actions.ActionArtifact has, err := db.GetEngine(ctx).Where(builder.Eq{"run_id": runID, "id": id}, builder.Like{"content_encoding", "/"}).Get(&art) if err != nil { diff --git a/tests/integration/api_actions_artifact_v4_test.go b/tests/integration/api_actions_artifact_v4_test.go index dd52d486af712..bc5b1f777e667 100644 --- a/tests/integration/api_actions_artifact_v4_test.go +++ b/tests/integration/api_actions_artifact_v4_test.go @@ -52,7 +52,7 @@ func TestActionsArtifactV4UploadSingleFile(t *testing.T) { name string version int32 mimeType string - blockId bool + blockID bool noLength bool }{ { @@ -72,13 +72,13 @@ func TestActionsArtifactV4UploadSingleFile(t *testing.T) { name: "artifact4.json", version: 7, mimeType: "application/json", - blockId: true, + blockID: true, }, { name: "artifact5.json", version: 7, mimeType: "application/json", - blockId: true, + blockID: true, noLength: true, }, { @@ -95,7 +95,6 @@ func TestActionsArtifactV4UploadSingleFile(t *testing.T) { } for _, entry := range table { - // acquire artifact upload url req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", toProtoJSON(&actions.CreateArtifactRequest{ Version: entry.version, @@ -113,7 +112,7 @@ func TestActionsArtifactV4UploadSingleFile(t *testing.T) { // get upload url idx := strings.Index(uploadResp.SignedUploadUrl, "/twirp/") url := uploadResp.SignedUploadUrl[idx:] + "&comp=block" - if entry.blockId { + if entry.blockID { url += "&blockid=" + base64.RawURLEncoding.EncodeToString([]byte("SOME_BIG_BLOCK_ID")) } From f032a5aa5e38a10685f18bb996eee0b236aab806 Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sun, 1 Mar 2026 15:43:47 +0100 Subject: [PATCH 14/78] fix comment --- routers/api/actions/artifactsv4.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index d4d5400bb7cbd..aa50156bacb56 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -324,7 +324,7 @@ func (r *artifactV4Routes) uploadArtifact(ctx *ArtifactContext) { case "block", "appendBlock": blockid := ctx.Req.URL.Query().Get("blockid") if blockid == "" { - // get artifact by name + // get artifact by id artifact, err := r.getArtifactByID(ctx, task.Job.RunID, artifactID) if err != nil { log.Error("Error artifact not found: %v", err) @@ -538,7 +538,7 @@ func (r *artifactV4Routes) downloadArtifact(ctx *ArtifactContext) { return } - // get artifact by name + // get artifact by id artifact, err := r.getArtifactByID(ctx, task.Job.RunID, artifactID) if err != nil { log.Error("Error artifact not found: %v", err) From cf03c2a2f61d0501f81012e544764d836b93a99a Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sun, 1 Mar 2026 15:44:46 +0100 Subject: [PATCH 15/78] add Content-Security-Policy + inline back * browser blocks scripts + accessing localstorage etc. --- routers/web/repo/actions/view.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 25314d3e5b26d..3c01459980d1f 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -800,7 +800,8 @@ func ArtifactsDownloadView(ctx *context_module.Context) { } if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) { - ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s; filename*=UTF-8''%s", url.PathEscape(artifacts[0].ArtifactPath), artifacts[0].ArtifactPath)) + ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%s; filename*=UTF-8''%s", url.PathEscape(artifacts[0].ArtifactPath), artifacts[0].ArtifactPath)) + ctx.Resp.Header().Set("Content-Security-Policy", "sandbox; default-src 'none';") err := actions.DownloadArtifactV4(ctx.Base, artifacts[0]) if err != nil { ctx.ServerError("DownloadArtifactV4", err) From 7a33ad9a4999cf1cf39213a00919eca638e903e8 Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sun, 1 Mar 2026 16:22:55 +0100 Subject: [PATCH 16/78] fix minio --- routers/api/actions/artifacts_chunks.go | 5 +++-- routers/api/actions/artifactsv4.go | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/routers/api/actions/artifacts_chunks.go b/routers/api/actions/artifacts_chunks.go index ce8da5dca1fa4..bdea92380153f 100644 --- a/routers/api/actions/artifacts_chunks.go +++ b/routers/api/actions/artifacts_chunks.go @@ -206,8 +206,9 @@ func listChunksByRunIDV4(st storage.ObjectStorage, runID, artifactID int64, blis chunk.End += chunk.Start } } - } else if len(chunks) < 1 { - return nil, errors.New("missing Chunk (no block map)") + } + if len(chunks) < 1 { + return nil, errors.New("no Chunk found") } return chunks, nil } diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index aa50156bacb56..6709f97d2c459 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -390,7 +390,10 @@ func (r *artifactV4Routes) readBlockList(runID, artifactID int64) (*BlockList, e if delerr != nil { log.Warn("Failed to delete blockList %s: %v", blockListName, delerr) } - return blockList, err + if err != nil { + return nil, err + } + return blockList, nil } func (r *artifactV4Routes) finalizeArtifact(ctx *ArtifactContext) { From c773028efd7a1448c7462c1bb271fd8728997d52 Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sun, 1 Mar 2026 16:42:42 +0100 Subject: [PATCH 17/78] Set content headers for internal download --- routers/api/actions/artifactsv4.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index 6709f97d2c459..a12dbd0e96395 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -554,6 +554,10 @@ func (r *artifactV4Routes) downloadArtifact(ctx *ArtifactContext) { return } + ctx.Resp.Header().Set("Content-Type", artifact.ContentEncoding) + ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%s; filename*=UTF-8''%s", url.PathEscape(artifact.ArtifactPath), artifact.ArtifactPath)) + ctx.Resp.Header().Set("Content-Security-Policy", "sandbox; default-src 'none';") + file, _ := r.fs.Open(artifact.StoragePath) _, _ = io.Copy(ctx.Resp, file) From fe9a14d7bb93f9b0001fd85cad539eadac129a37 Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sun, 1 Mar 2026 18:02:39 +0100 Subject: [PATCH 18/78] test blob storage content type --- modules/storage/azureblob.go | 35 +++++++- routers/api/actions/artifactsv4.go | 7 +- .../api_actions_artifact_v4_test.go | 84 +++++++++++++------ tests/integration/integration_test.go | 33 +++++++- 4 files changed, 127 insertions(+), 32 deletions(-) diff --git a/modules/storage/azureblob.go b/modules/storage/azureblob.go index e7297cec77a0f..5f0040fcb5c7a 100644 --- a/modules/storage/azureblob.go +++ b/modules/storage/azureblob.go @@ -247,10 +247,41 @@ func (a *AzureBlobStorage) Delete(path string) error { } // URL gets the redirect URL to a file. The presigned link is valid for 5 minutes. -func (a *AzureBlobStorage) URL(path, name, _ string, reqParams url.Values) (*url.URL, error) { - blobClient := a.getBlobClient(path) +func (a *AzureBlobStorage) URL(storePath, name, _ string, reqParams url.Values) (*url.URL, error) { + blobClient := a.getBlobClient(storePath) // TODO: OBJECT-STORAGE-CONTENT-TYPE: "browser inline rendering images/PDF" needs proper Content-Type header from storage + // copy serveDirectReqParams + reqParams, err := url.ParseQuery(reqParams.Encode()) + if err != nil { + return nil, err + } + + // Here we might not know the real filename, and it's quite inefficient to detect the mine type by pre-fetching the object head. + // So we just do a quick detection by extension name, at least if works for the "View Raw File" for an LFS file on the Web UI. + // Detect content type by extension name, only support the well-known safe types for inline rendering. + // TODO: OBJECT-STORAGE-CONTENT-TYPE: need a complete solution and refactor for Azure in the future + ext := path.Ext(name) + inlineExtMimeTypes := map[string]string{ + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".avif": "image/avif", + // ATTENTION! Don't support unsafe types like HTML/SVG due to security concerns: they can contain JS code, and maybe they need proper Content-Security-Policy + // HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context, it seems fine to render it inline + ".pdf": "application/pdf", + + // TODO: refactor with "modules/public/mime_types.go", for example: "DetectWellKnownSafeInlineMimeType" + } + if mimeType, ok := inlineExtMimeTypes[ext]; ok { + reqParams.Set("rsct", mimeType) + reqParams.Set("rscd", "inline") + } else { + reqParams.Set("rscd", fmt.Sprintf(`attachment; filename="%s"`, quoteEscaper.Replace(name))) + } + startTime := time.Now() u, err := blobClient.GetSASURL(sas.BlobPermissions{ Read: true, diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index a12dbd0e96395..459247f1215a7 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -524,7 +524,12 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) { respData := GetSignedArtifactURLResponse{} if setting.Actions.ArtifactStorage.ServeDirect() { - u, err := storage.ActionsArtifacts.URL(artifact.StoragePath, artifact.ArtifactPath, ctx.Req.Method, nil) + reqParams := url.Values{} + reqParams.Set("response-content-type", artifact.ContentEncoding) + reqParams.Set("rsct", artifact.ContentEncoding) + // TODO + // reqParams.Set("Content-Disposition", fmt.Sprintf("inline; filename=%s; filename*=UTF-8''%s", url.PathEscape(artifact.ArtifactPath), artifact.ArtifactPath)) + u, err := storage.ActionsArtifacts.URL(artifact.StoragePath, artifact.ArtifactPath, ctx.Req.Method, reqParams) if u != nil && err == nil { respData.SignedUrl = u.String() } diff --git a/tests/integration/api_actions_artifact_v4_test.go b/tests/integration/api_actions_artifact_v4_test.go index bc5b1f777e667..5c67fa8a20114 100644 --- a/tests/integration/api_actions_artifact_v4_test.go +++ b/tests/integration/api_actions_artifact_v4_test.go @@ -22,8 +22,10 @@ import ( "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/api/actions" actions_service "code.gitea.io/gitea/services/actions" @@ -378,33 +380,63 @@ func TestActionsArtifactV4DownloadSingle(t *testing.T) { token, err := actions_service.CreateAuthorizationToken(48, 792, 193) assert.NoError(t, err) - // acquire artifact upload url - req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/ListArtifacts", toProtoJSON(&actions.ListArtifactsRequest{ - NameFilter: wrapperspb.String("artifact-v4-download"), - WorkflowRunBackendId: "792", - WorkflowJobRunBackendId: "193", - })).AddTokenAuth(token) - resp := MakeRequest(t, req, http.StatusOK) - var listResp actions.ListArtifactsResponse - protojson.Unmarshal(resp.Body.Bytes(), &listResp) - assert.Len(t, listResp.Artifacts, 1) - - // confirm artifact upload - req = NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/GetSignedArtifactURL", toProtoJSON(&actions.GetSignedArtifactURLRequest{ - Name: "artifact-v4-download", - WorkflowRunBackendId: "792", - WorkflowJobRunBackendId: "193", - })). - AddTokenAuth(token) - resp = MakeRequest(t, req, http.StatusOK) - var finalizeResp actions.GetSignedArtifactURLResponse - protojson.Unmarshal(resp.Body.Bytes(), &finalizeResp) - assert.NotEmpty(t, finalizeResp.SignedUrl) + table := []struct { + Name string + ServeDirect bool + }{ + {Name: "Download"}, + {Name: "ServeDirect", ServeDirect: true}, + } - req = NewRequest(t, "GET", finalizeResp.SignedUrl) - resp = MakeRequest(t, req, http.StatusOK) - body := strings.Repeat("D", 1024) - assert.Equal(t, body, resp.Body.String()) + for _, entry := range table { + // Skip tests if not applicable + if entry.ServeDirect && setting.Actions.ArtifactStorage.Type != setting.AzureBlobStorageType && setting.Actions.ArtifactStorage.Type != setting.MinioStorageType { + continue + } + t.Run(entry.Name, func(t *testing.T) { + if entry.ServeDirect { + switch setting.Actions.ArtifactStorage.Type { + case setting.AzureBlobStorageType: + defer test.MockVariableValue(&setting.Actions.ArtifactStorage.AzureBlobConfig.ServeDirect, true)() + case setting.MinioStorageType: + defer test.MockVariableValue(&setting.Actions.ArtifactStorage.MinioConfig.ServeDirect, true)() + default: + t.Skip() + } + } + + // acquire artifact upload url + req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/ListArtifacts", toProtoJSON(&actions.ListArtifactsRequest{ + NameFilter: wrapperspb.String("artifact-v4-download"), + WorkflowRunBackendId: "792", + WorkflowJobRunBackendId: "193", + })).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var listResp actions.ListArtifactsResponse + protojson.Unmarshal(resp.Body.Bytes(), &listResp) + assert.Len(t, listResp.Artifacts, 1) + + // confirm artifact upload + req = NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/GetSignedArtifactURL", toProtoJSON(&actions.GetSignedArtifactURLRequest{ + Name: "artifact-v4-download", + WorkflowRunBackendId: "792", + WorkflowJobRunBackendId: "193", + })). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var finalizeResp actions.GetSignedArtifactURLResponse + protojson.Unmarshal(resp.Body.Bytes(), &finalizeResp) + assert.NotEmpty(t, finalizeResp.SignedUrl) + + req = NewRequest(t, "GET", finalizeResp.SignedUrl) + resp = MakeRequest(t, req, http.StatusOK) + // TODO add test data for other file types + assert.Equal(t, actions.ArtifactV4ContentEncoding, resp.Header().Get("Content-Type")) + // TODO add a CSP test + body := strings.Repeat("D", 1024) + assert.Equal(t, body, resp.Body.String()) + }) + } } func TestActionsArtifactV4RunDownloadSinglePublicApi(t *testing.T) { diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go index ca1e094ac27df..96e350d5745a1 100644 --- a/tests/integration/integration_test.go +++ b/tests/integration/integration_test.go @@ -10,6 +10,7 @@ import ( "hash" "hash/fnv" "io" + "maps" "net/http" "net/http/cookiejar" "net/http/httptest" @@ -334,14 +335,40 @@ func NewRequestWithBody(t testing.TB, method, urlStr string, body io.Reader) *Re const NoExpectedStatus = 0 +func isEndpoint(st *setting.Storage, remoteAddr string) bool { + return st.Type == setting.MinioStorageType && remoteAddr == st.MinioConfig.Endpoint || + st.Type == setting.AzureBlobStorageType && remoteAddr == st.AzureBlobConfig.Endpoint +} + func MakeRequest(t testing.TB, rw *RequestWrapper, expectedStatus int) *httptest.ResponseRecorder { t.Helper() req := rw.Request recorder := httptest.NewRecorder() - if req.RemoteAddr == "" { - req.RemoteAddr = "test-mock:12345" + if isEndpoint(setting.Avatar.Storage, req.URL.Host) || + isEndpoint(setting.Attachment.Storage, req.URL.Host) || + isEndpoint(setting.LFS.Storage, req.URL.Host) || + isEndpoint(setting.RepoAvatar.Storage, req.URL.Host) || + isEndpoint(setting.RepoArchive.Storage, req.URL.Host) || + isEndpoint(setting.Packages.Storage, req.URL.Host) || + isEndpoint(setting.Actions.LogStorage, req.URL.Host) || + isEndpoint(setting.Actions.ArtifactStorage, req.URL.Host) { + rw.Request.RequestURI = "" + resp, err := http.DefaultClient.Do(rw.Request) + if err != nil { + recorder.WriteHeader(500) + _, _ = recorder.WriteString(err.Error()) + } else { + defer func() { _ = resp.Body.Close() }() + maps.Copy(recorder.Header(), resp.Header) + recorder.WriteHeader(resp.StatusCode) + io.Copy(recorder.Body, resp.Body) + } + } else { + if req.RemoteAddr == "" { + req.RemoteAddr = "test-mock:12345" + } + testWebRoutes.ServeHTTP(recorder, req) } - testWebRoutes.ServeHTTP(recorder, req) if expectedStatus != NoExpectedStatus { if expectedStatus != recorder.Code { logUnexpectedResponse(t, recorder) From ceb933eefcd380a8b140fde27e9d4d0f9d97dd5a Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sun, 1 Mar 2026 19:15:53 +0100 Subject: [PATCH 19/78] fixes --- tests/integration/api_actions_artifact_v4_test.go | 14 +++++++------- tests/integration/integration_test.go | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/integration/api_actions_artifact_v4_test.go b/tests/integration/api_actions_artifact_v4_test.go index 5c67fa8a20114..2d13bb074054d 100644 --- a/tests/integration/api_actions_artifact_v4_test.go +++ b/tests/integration/api_actions_artifact_v4_test.go @@ -394,13 +394,13 @@ func TestActionsArtifactV4DownloadSingle(t *testing.T) { continue } t.Run(entry.Name, func(t *testing.T) { - if entry.ServeDirect { - switch setting.Actions.ArtifactStorage.Type { - case setting.AzureBlobStorageType: - defer test.MockVariableValue(&setting.Actions.ArtifactStorage.AzureBlobConfig.ServeDirect, true)() - case setting.MinioStorageType: - defer test.MockVariableValue(&setting.Actions.ArtifactStorage.MinioConfig.ServeDirect, true)() - default: + switch setting.Actions.ArtifactStorage.Type { + case setting.AzureBlobStorageType: + defer test.MockVariableValue(&setting.Actions.ArtifactStorage.AzureBlobConfig.ServeDirect, entry.ServeDirect)() + case setting.MinioStorageType: + defer test.MockVariableValue(&setting.Actions.ArtifactStorage.MinioConfig.ServeDirect, entry.ServeDirect)() + default: + if entry.ServeDirect { t.Skip() } } diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go index 96e350d5745a1..f7d606584c108 100644 --- a/tests/integration/integration_test.go +++ b/tests/integration/integration_test.go @@ -337,7 +337,7 @@ const NoExpectedStatus = 0 func isEndpoint(st *setting.Storage, remoteAddr string) bool { return st.Type == setting.MinioStorageType && remoteAddr == st.MinioConfig.Endpoint || - st.Type == setting.AzureBlobStorageType && remoteAddr == st.AzureBlobConfig.Endpoint + st.Type == setting.AzureBlobStorageType && ("http://"+remoteAddr == st.AzureBlobConfig.Endpoint || "https://"+remoteAddr == st.AzureBlobConfig.Endpoint) } func MakeRequest(t testing.TB, rw *RequestWrapper, expectedStatus int) *httptest.ResponseRecorder { @@ -355,7 +355,7 @@ func MakeRequest(t testing.TB, rw *RequestWrapper, expectedStatus int) *httptest rw.Request.RequestURI = "" resp, err := http.DefaultClient.Do(rw.Request) if err != nil { - recorder.WriteHeader(500) + recorder.WriteHeader(http.StatusInternalServerError) _, _ = recorder.WriteString(err.Error()) } else { defer func() { _ = resp.Body.Close() }() From b8b3d675f2f16369057a314411d48887c127b63f Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sun, 1 Mar 2026 19:31:17 +0100 Subject: [PATCH 20/78] Merge reqParams before returning --- modules/storage/azureblob.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/modules/storage/azureblob.go b/modules/storage/azureblob.go index 5f0040fcb5c7a..2d3b4dec7653d 100644 --- a/modules/storage/azureblob.go +++ b/modules/storage/azureblob.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "maps" "net/url" "os" "path" @@ -292,7 +293,14 @@ func (a *AzureBlobStorage) URL(storePath, name, _ string, reqParams url.Values) return nil, convertAzureBlobErr(err) } - return url.Parse(u) + // Merge reqParams before returning + uu, err := url.Parse(u) + if err != nil { + return nil, err + } + maps.Copy(reqParams, uu.Query()) + uu.RawQuery = reqParams.Encode() + return uu, err } // IterateObjects iterates across the objects in the azureblobstorage From 3c3d45d3827575bcb3a6b9330a469cd227cf0c75 Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sun, 1 Mar 2026 19:44:05 +0100 Subject: [PATCH 21/78] . --- modules/storage/azureblob.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/modules/storage/azureblob.go b/modules/storage/azureblob.go index 2d3b4dec7653d..851ed92e8f904 100644 --- a/modules/storage/azureblob.go +++ b/modules/storage/azureblob.go @@ -8,7 +8,6 @@ import ( "errors" "fmt" "io" - "maps" "net/url" "os" "path" @@ -276,6 +275,7 @@ func (a *AzureBlobStorage) URL(storePath, name, _ string, reqParams url.Values) // TODO: refactor with "modules/public/mime_types.go", for example: "DetectWellKnownSafeInlineMimeType" } + // https://learn.microsoft.com/en-us/rest/api/storageservices/service-sas-examples if mimeType, ok := inlineExtMimeTypes[ext]; ok { reqParams.Set("rsct", mimeType) reqParams.Set("rscd", "inline") @@ -293,13 +293,12 @@ func (a *AzureBlobStorage) URL(storePath, name, _ string, reqParams url.Values) return nil, convertAzureBlobErr(err) } - // Merge reqParams before returning + // Append reqParams before returning uu, err := url.Parse(u) if err != nil { return nil, err } - maps.Copy(reqParams, uu.Query()) - uu.RawQuery = reqParams.Encode() + uu.RawQuery += "&" + reqParams.Encode() return uu, err } From 07fbdbcee830b2574af2b89e977190d22dd94e8a Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sun, 1 Mar 2026 20:55:43 +0100 Subject: [PATCH 22/78] ... --- modules/storage/azureblob.go | 51 ++++++++++++++----- .../api_actions_artifact_v4_test.go | 4 -- tests/integration/integration_test.go | 7 ++- 3 files changed, 44 insertions(+), 18 deletions(-) diff --git a/modules/storage/azureblob.go b/modules/storage/azureblob.go index 851ed92e8f904..05ef266ef5662 100644 --- a/modules/storage/azureblob.go +++ b/modules/storage/azureblob.go @@ -246,6 +246,34 @@ func (a *AzureBlobStorage) Delete(path string) error { return convertAzureBlobErr(err) } +func (a *AzureBlobStorage) GetSasURL(b *blob.Client, template sas.BlobSignatureValues) (string, error) { + urlParts, err := blob.ParseURL(b.URL()) + if err != nil { + return "", err + } + + t, err := time.Parse(blob.SnapshotTimeFormat, urlParts.Snapshot) + + if err != nil { + t = time.Time{} + } + + template.ContainerName = urlParts.ContainerName + template.BlobName = urlParts.BlobName + template.SnapshotTime = t + template.Version = sas.Version + + qps, err := template.SignWithSharedKey(a.credential) + + if err != nil { + return "", err + } + + endpoint := b.URL() + "?" + qps.Encode() + + return endpoint, nil +} + // URL gets the redirect URL to a file. The presigned link is valid for 5 minutes. func (a *AzureBlobStorage) URL(storePath, name, _ string, reqParams url.Values) (*url.URL, error) { blobClient := a.getBlobClient(storePath) @@ -283,23 +311,22 @@ func (a *AzureBlobStorage) URL(storePath, name, _ string, reqParams url.Values) reqParams.Set("rscd", fmt.Sprintf(`attachment; filename="%s"`, quoteEscaper.Replace(name))) } - startTime := time.Now() - u, err := blobClient.GetSASURL(sas.BlobPermissions{ - Read: true, - }, time.Now().Add(5*time.Minute), &blob.GetSASURLOptions{ - StartTime: &startTime, + startTime := time.Now().UTC() + + u, err := a.GetSasURL(blobClient, sas.BlobSignatureValues{ + Permissions: (&sas.BlobPermissions{ + Read: true, + }).String(), + StartTime: startTime, + ExpiryTime: startTime.Add(5 * time.Minute), + ContentDisposition: reqParams.Get("rscd"), + ContentType: reqParams.Get("rsct"), }) if err != nil { return nil, convertAzureBlobErr(err) } - // Append reqParams before returning - uu, err := url.Parse(u) - if err != nil { - return nil, err - } - uu.RawQuery += "&" + reqParams.Encode() - return uu, err + return url.Parse(u) } // IterateObjects iterates across the objects in the azureblobstorage diff --git a/tests/integration/api_actions_artifact_v4_test.go b/tests/integration/api_actions_artifact_v4_test.go index 2d13bb074054d..56eee98a4320d 100644 --- a/tests/integration/api_actions_artifact_v4_test.go +++ b/tests/integration/api_actions_artifact_v4_test.go @@ -389,10 +389,6 @@ func TestActionsArtifactV4DownloadSingle(t *testing.T) { } for _, entry := range table { - // Skip tests if not applicable - if entry.ServeDirect && setting.Actions.ArtifactStorage.Type != setting.AzureBlobStorageType && setting.Actions.ArtifactStorage.Type != setting.MinioStorageType { - continue - } t.Run(entry.Name, func(t *testing.T) { switch setting.Actions.ArtifactStorage.Type { case setting.AzureBlobStorageType: diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go index f7d606584c108..4259c0cb9ea2e 100644 --- a/tests/integration/integration_test.go +++ b/tests/integration/integration_test.go @@ -336,8 +336,11 @@ func NewRequestWithBody(t testing.TB, method, urlStr string, body io.Reader) *Re const NoExpectedStatus = 0 func isEndpoint(st *setting.Storage, remoteAddr string) bool { - return st.Type == setting.MinioStorageType && remoteAddr == st.MinioConfig.Endpoint || - st.Type == setting.AzureBlobStorageType && ("http://"+remoteAddr == st.AzureBlobConfig.Endpoint || "https://"+remoteAddr == st.AzureBlobConfig.Endpoint) + if st.Type == setting.AzureBlobStorageType { + endp, err := url.Parse(st.AzureBlobConfig.Endpoint) + return err != nil && endp.Host == remoteAddr + } + return st.Type == setting.MinioStorageType && remoteAddr == st.MinioConfig.Endpoint } func MakeRequest(t testing.TB, rw *RequestWrapper, expectedStatus int) *httptest.ResponseRecorder { From cfb3e7e8893193d334596ab096c232deda78c6bb Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sun, 1 Mar 2026 20:59:58 +0100 Subject: [PATCH 23/78] fix test --- tests/integration/integration_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go index 4259c0cb9ea2e..9fc9478606076 100644 --- a/tests/integration/integration_test.go +++ b/tests/integration/integration_test.go @@ -338,7 +338,7 @@ const NoExpectedStatus = 0 func isEndpoint(st *setting.Storage, remoteAddr string) bool { if st.Type == setting.AzureBlobStorageType { endp, err := url.Parse(st.AzureBlobConfig.Endpoint) - return err != nil && endp.Host == remoteAddr + return err == nil && endp.Host == remoteAddr } return st.Type == setting.MinioStorageType && remoteAddr == st.MinioConfig.Endpoint } From 754c601dacdd185ee96cddc11f88f394fe16c457 Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sun, 1 Mar 2026 21:04:20 +0100 Subject: [PATCH 24/78] fixes --- modules/storage/azureblob.go | 2 -- tests/integration/integration_test.go | 3 +++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/storage/azureblob.go b/modules/storage/azureblob.go index 05ef266ef5662..6b5a560c3cb8c 100644 --- a/modules/storage/azureblob.go +++ b/modules/storage/azureblob.go @@ -253,7 +253,6 @@ func (a *AzureBlobStorage) GetSasURL(b *blob.Client, template sas.BlobSignatureV } t, err := time.Parse(blob.SnapshotTimeFormat, urlParts.Snapshot) - if err != nil { t = time.Time{} } @@ -264,7 +263,6 @@ func (a *AzureBlobStorage) GetSasURL(b *blob.Client, template sas.BlobSignatureV template.Version = sas.Version qps, err := template.SignWithSharedKey(a.credential) - if err != nil { return "", err } diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go index 9fc9478606076..581eeb3f74fe8 100644 --- a/tests/integration/integration_test.go +++ b/tests/integration/integration_test.go @@ -336,6 +336,9 @@ func NewRequestWithBody(t testing.TB, method, urlStr string, body io.Reader) *Re const NoExpectedStatus = 0 func isEndpoint(st *setting.Storage, remoteAddr string) bool { + if !st.ServeDirect() { + return false + } if st.Type == setting.AzureBlobStorageType { endp, err := url.Parse(st.AzureBlobConfig.Endpoint) return err == nil && endp.Host == remoteAddr From c302794dcaf107467854f21d9decc46083034652 Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sat, 7 Mar 2026 23:00:13 +0100 Subject: [PATCH 25/78] cleanup --- routers/api/actions/artifacts_chunks.go | 1 - routers/api/actions/artifactsv4.go | 33 ++++++++----------------- 2 files changed, 10 insertions(+), 24 deletions(-) diff --git a/routers/api/actions/artifacts_chunks.go b/routers/api/actions/artifacts_chunks.go index 422d5f3342d51..5cffebd5c6d15 100644 --- a/routers/api/actions/artifacts_chunks.go +++ b/routers/api/actions/artifacts_chunks.go @@ -22,7 +22,6 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" - "code.gitea.io/gitea/modules/util" ) type saveUploadChunkOptions struct { diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index 01456e66e4dca..a153d1b65eac8 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -239,42 +239,31 @@ func (r *artifactV4Routes) verifySignature(ctx *ArtifactContext, endp string) (* if !hmac.Equal(dsig, expecedsig) { log.Error("Error unauthorized") ctx.HTTPError(http.StatusUnauthorized, "Error unauthorized") - return nil, 0, false + 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, 0, false + 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, 0, false + 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, 0, false + 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, 0, false - } - return task, artifactID, true -} - -func (r *artifactV4Routes) getArtifactByID(ctx *ArtifactContext, runID, id int64) (*actions.ActionArtifact, error) { - var art actions.ActionArtifact - has, err := db.GetEngine(ctx).Where(builder.Eq{"run_id": runID, "id": id}, builder.Like{"content_encoding", "/"}).Get(&art) - if err != nil { - return nil, err - } else if !has { - return nil, util.ErrNotExist + return nil, "", false } - return &art, nil + return task, artifactName, true } func (r *artifactV4Routes) getArtifactByName(ctx *ArtifactContext, runID int64, name string) (*actions.ActionArtifact, error) { @@ -363,7 +352,7 @@ func (r *artifactV4Routes) createArtifact(ctx *ArtifactContext) { } func (r *artifactV4Routes) uploadArtifact(ctx *ArtifactContext) { - task, artifactID, ok := r.verifySignature(ctx, "UploadArtifact") + task, artifactName, ok := r.verifySignature(ctx, "UploadArtifact") if !ok { return } @@ -482,8 +471,6 @@ func (r *artifactV4Routes) finalizeArtifact(ctx *ArtifactContext) { ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks size mismatch") return } - artifact.FileSize = chunks[len(chunks)-1].End + 1 - artifact.FileCompressedSize = chunks[len(chunks)-1].End + 1 checksum := "" if req.Hash != nil { @@ -600,13 +587,13 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) { } func (r *artifactV4Routes) downloadArtifact(ctx *ArtifactContext) { - task, artifactID, ok := r.verifySignature(ctx, "DownloadArtifact") + task, artifactName, ok := r.verifySignature(ctx, "DownloadArtifact") if !ok { return } - // get artifact by id - artifact, err := r.getArtifactByID(ctx, task.Job.RunID, artifactID) + // 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") From fbf28e8fc4cf395005231dd0b20457059ee27c55 Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sat, 7 Mar 2026 23:00:34 +0100 Subject: [PATCH 26/78] fix --- tests/integration/api_actions_artifact_v4_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/api_actions_artifact_v4_test.go b/tests/integration/api_actions_artifact_v4_test.go index a7dfba2239e38..f1da21b053323 100644 --- a/tests/integration/api_actions_artifact_v4_test.go +++ b/tests/integration/api_actions_artifact_v4_test.go @@ -16,7 +16,6 @@ import ( "testing" "time" - actions_model "code.gitea.io/gitea/models/actions" auth_model "code.gitea.io/gitea/models/auth" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" @@ -25,6 +24,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/api/actions" actions_service "code.gitea.io/gitea/services/actions" From 10a7cae515e80d8050567f575e53efc795182af4 Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sat, 7 Mar 2026 23:05:11 +0100 Subject: [PATCH 27/78] cleanup --- modules/storage/azureblob.go | 8 ++++---- routers/api/actions/artifactsv4.go | 4 +--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/modules/storage/azureblob.go b/modules/storage/azureblob.go index 6b5a560c3cb8c..d774f9c166018 100644 --- a/modules/storage/azureblob.go +++ b/modules/storage/azureblob.go @@ -303,10 +303,10 @@ func (a *AzureBlobStorage) URL(storePath, name, _ string, reqParams url.Values) } // https://learn.microsoft.com/en-us/rest/api/storageservices/service-sas-examples if mimeType, ok := inlineExtMimeTypes[ext]; ok { - reqParams.Set("rsct", mimeType) - reqParams.Set("rscd", "inline") + reqParams.Set("response-content-type", mimeType) + reqParams.Set("response-content-disposition", "inline") } else { - reqParams.Set("rscd", fmt.Sprintf(`attachment; filename="%s"`, quoteEscaper.Replace(name))) + reqParams.Set("response-content-disposition", fmt.Sprintf(`attachment; filename="%s"`, quoteEscaper.Replace(name))) } startTime := time.Now().UTC() @@ -318,7 +318,7 @@ func (a *AzureBlobStorage) URL(storePath, name, _ string, reqParams url.Values) StartTime: startTime, ExpiryTime: startTime.Add(5 * time.Minute), ContentDisposition: reqParams.Get("rscd"), - ContentType: reqParams.Get("rsct"), + ContentType: reqParams.Get("response-content-type"), }) if err != nil { return nil, convertAzureBlobErr(err) diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index a153d1b65eac8..4366e28b29519 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -572,9 +572,7 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) { if setting.Actions.ArtifactStorage.ServeDirect() { reqParams := url.Values{} reqParams.Set("response-content-type", artifact.ContentEncoding) - reqParams.Set("rsct", artifact.ContentEncoding) - // TODO - // reqParams.Set("Content-Disposition", fmt.Sprintf("inline; filename=%s; filename*=UTF-8''%s", url.PathEscape(artifact.ArtifactPath), artifact.ArtifactPath)) + reqParams.Set("response-content-disposition", fmt.Sprintf("inline; filename=%s; filename*=UTF-8''%s", url.PathEscape(artifact.ArtifactPath), artifact.ArtifactPath)) u, err := storage.ActionsArtifacts.URL(artifact.StoragePath, artifact.ArtifactPath, ctx.Req.Method, reqParams) if u != nil && err == nil { respData.SignedUrl = u.String() From 1ea461159ed23392472581d9d001fb81920c5dbb Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sat, 7 Mar 2026 23:14:47 +0100 Subject: [PATCH 28/78] Remove hacks * ServeDirect Content-Type and Content-Disposition are partially broken in minio and not implemented azure --- modules/storage/azureblob.go | 77 ++----------------- .../api_actions_artifact_v4_test.go | 4 +- tests/integration/integration_test.go | 38 +-------- 3 files changed, 12 insertions(+), 107 deletions(-) diff --git a/modules/storage/azureblob.go b/modules/storage/azureblob.go index d774f9c166018..e7297cec77a0f 100644 --- a/modules/storage/azureblob.go +++ b/modules/storage/azureblob.go @@ -246,79 +246,16 @@ func (a *AzureBlobStorage) Delete(path string) error { return convertAzureBlobErr(err) } -func (a *AzureBlobStorage) GetSasURL(b *blob.Client, template sas.BlobSignatureValues) (string, error) { - urlParts, err := blob.ParseURL(b.URL()) - if err != nil { - return "", err - } - - t, err := time.Parse(blob.SnapshotTimeFormat, urlParts.Snapshot) - if err != nil { - t = time.Time{} - } - - template.ContainerName = urlParts.ContainerName - template.BlobName = urlParts.BlobName - template.SnapshotTime = t - template.Version = sas.Version - - qps, err := template.SignWithSharedKey(a.credential) - if err != nil { - return "", err - } - - endpoint := b.URL() + "?" + qps.Encode() - - return endpoint, nil -} - // URL gets the redirect URL to a file. The presigned link is valid for 5 minutes. -func (a *AzureBlobStorage) URL(storePath, name, _ string, reqParams url.Values) (*url.URL, error) { - blobClient := a.getBlobClient(storePath) +func (a *AzureBlobStorage) URL(path, name, _ string, reqParams url.Values) (*url.URL, error) { + blobClient := a.getBlobClient(path) // TODO: OBJECT-STORAGE-CONTENT-TYPE: "browser inline rendering images/PDF" needs proper Content-Type header from storage - // copy serveDirectReqParams - reqParams, err := url.ParseQuery(reqParams.Encode()) - if err != nil { - return nil, err - } - - // Here we might not know the real filename, and it's quite inefficient to detect the mine type by pre-fetching the object head. - // So we just do a quick detection by extension name, at least if works for the "View Raw File" for an LFS file on the Web UI. - // Detect content type by extension name, only support the well-known safe types for inline rendering. - // TODO: OBJECT-STORAGE-CONTENT-TYPE: need a complete solution and refactor for Azure in the future - ext := path.Ext(name) - inlineExtMimeTypes := map[string]string{ - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - ".avif": "image/avif", - // ATTENTION! Don't support unsafe types like HTML/SVG due to security concerns: they can contain JS code, and maybe they need proper Content-Security-Policy - // HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context, it seems fine to render it inline - ".pdf": "application/pdf", - - // TODO: refactor with "modules/public/mime_types.go", for example: "DetectWellKnownSafeInlineMimeType" - } - // https://learn.microsoft.com/en-us/rest/api/storageservices/service-sas-examples - if mimeType, ok := inlineExtMimeTypes[ext]; ok { - reqParams.Set("response-content-type", mimeType) - reqParams.Set("response-content-disposition", "inline") - } else { - reqParams.Set("response-content-disposition", fmt.Sprintf(`attachment; filename="%s"`, quoteEscaper.Replace(name))) - } - - startTime := time.Now().UTC() - - u, err := a.GetSasURL(blobClient, sas.BlobSignatureValues{ - Permissions: (&sas.BlobPermissions{ - Read: true, - }).String(), - StartTime: startTime, - ExpiryTime: startTime.Add(5 * time.Minute), - ContentDisposition: reqParams.Get("rscd"), - ContentType: reqParams.Get("response-content-type"), + startTime := time.Now() + u, err := blobClient.GetSASURL(sas.BlobPermissions{ + Read: true, + }, time.Now().Add(5*time.Minute), &blob.GetSASURLOptions{ + StartTime: &startTime, }) if err != nil { return nil, convertAzureBlobErr(err) diff --git a/tests/integration/api_actions_artifact_v4_test.go b/tests/integration/api_actions_artifact_v4_test.go index f1da21b053323..3d341d2fd10ae 100644 --- a/tests/integration/api_actions_artifact_v4_test.go +++ b/tests/integration/api_actions_artifact_v4_test.go @@ -411,7 +411,8 @@ func TestActionsArtifactV4DownloadSingle(t *testing.T) { ServeDirect bool }{ {Name: "Download"}, - {Name: "ServeDirect", ServeDirect: true}, + // FIXME ServeDirect Content-Type and Content-Disposition are partially broken in minio and not implemented azure + // {Name: "ServeDirect", ServeDirect: true}, } for _, entry := range table { @@ -450,6 +451,7 @@ func TestActionsArtifactV4DownloadSingle(t *testing.T) { protojson.Unmarshal(resp.Body.Bytes(), &finalizeResp) assert.NotEmpty(t, finalizeResp.SignedUrl) + // FIXME use real http client if ServeDirect is true req = NewRequest(t, "GET", finalizeResp.SignedUrl) resp = MakeRequest(t, req, http.StatusOK) // TODO add test data for other file types diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go index e4dc8b0f763ed..8c2942a01232c 100644 --- a/tests/integration/integration_test.go +++ b/tests/integration/integration_test.go @@ -10,7 +10,6 @@ import ( "hash" "hash/fnv" "io" - "maps" "net/http" "net/http/cookiejar" "net/http/httptest" @@ -335,45 +334,12 @@ func NewRequestWithBody(t testing.TB, method, urlStr string, body io.Reader) *Re const NoExpectedStatus = 0 -func isEndpoint(st *setting.Storage, remoteAddr string) bool { - if !st.ServeDirect() { - return false - } - if st.Type == setting.AzureBlobStorageType { - endp, err := url.Parse(st.AzureBlobConfig.Endpoint) - return err == nil && endp.Host == remoteAddr - } - return st.Type == setting.MinioStorageType && remoteAddr == st.MinioConfig.Endpoint -} - func MakeRequest(t testing.TB, rw *RequestWrapper, expectedStatus int) *httptest.ResponseRecorder { t.Helper() req := rw.Request recorder := httptest.NewRecorder() - if isEndpoint(setting.Avatar.Storage, req.URL.Host) || - isEndpoint(setting.Attachment.Storage, req.URL.Host) || - isEndpoint(setting.LFS.Storage, req.URL.Host) || - isEndpoint(setting.RepoAvatar.Storage, req.URL.Host) || - isEndpoint(setting.RepoArchive.Storage, req.URL.Host) || - isEndpoint(setting.Packages.Storage, req.URL.Host) || - isEndpoint(setting.Actions.LogStorage, req.URL.Host) || - isEndpoint(setting.Actions.ArtifactStorage, req.URL.Host) { - rw.Request.RequestURI = "" - resp, err := http.DefaultClient.Do(rw.Request) - if err != nil { - recorder.WriteHeader(http.StatusInternalServerError) - _, _ = recorder.WriteString(err.Error()) - } else { - defer func() { _ = resp.Body.Close() }() - maps.Copy(recorder.Header(), resp.Header) - recorder.WriteHeader(resp.StatusCode) - io.Copy(recorder.Body, resp.Body) - } - } else { - if req.RemoteAddr == "" { - req.RemoteAddr = "test-mock:12345" - } - testWebRoutes.ServeHTTP(recorder, req) + if req.RemoteAddr == "" { + req.RemoteAddr = "test-mock:12345" } // Ensure unknown contentLength is seen as -1 if req.Body != nil && req.ContentLength == 0 { From 893a0bea22388e80223d44066ad45850544ff892 Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sat, 7 Mar 2026 23:15:27 +0100 Subject: [PATCH 29/78] revert --- routers/api/actions/artifacts_chunks.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routers/api/actions/artifacts_chunks.go b/routers/api/actions/artifacts_chunks.go index 5cffebd5c6d15..86a51d6ca64bc 100644 --- a/routers/api/actions/artifacts_chunks.go +++ b/routers/api/actions/artifacts_chunks.go @@ -221,7 +221,7 @@ func listOrderedChunksForArtifact(st storage.ObjectStorage, runID, artifactID in chunks = []*chunkFileItem{item} } return nil - }); err != nil && (chunks != nil || blist != nil) { + }); err != nil { return nil, err } From ff313593ba85c599db2d2e82038e48c28ee5692b Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sat, 7 Mar 2026 23:17:22 +0100 Subject: [PATCH 30/78] add missing code for webui content-type serve direct --- modules/actions/artifacts.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/modules/actions/artifacts.go b/modules/actions/artifacts.go index d3bd7eb73bcf5..56a3a14a8ec73 100644 --- a/modules/actions/artifacts.go +++ b/modules/actions/artifacts.go @@ -4,7 +4,9 @@ package actions import ( + "fmt" "net/http" + "net/url" "strings" actions_model "code.gitea.io/gitea/models/actions" @@ -21,6 +23,9 @@ func IsArtifactV4(art *actions_model.ActionArtifact) bool { func DownloadArtifactV4ServeDirectOnly(ctx *context.Base, art *actions_model.ActionArtifact) (bool, error) { if setting.Actions.ArtifactStorage.ServeDirect() { + reqParams := url.Values{} + reqParams.Set("response-content-type", art.ContentEncoding) + reqParams.Set("response-content-disposition", fmt.Sprintf("inline; filename=%s; filename*=UTF-8''%s", url.PathEscape(art.ArtifactPath), art.ArtifactPath)) u, err := storage.ActionsArtifacts.URL(art.StoragePath, art.ArtifactPath, ctx.Req.Method, nil) if u != nil && err == nil { ctx.Redirect(u.String(), http.StatusFound) From 68f5ee90487ae52dc433937f0d19d35e6c4d96e4 Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sat, 7 Mar 2026 23:51:27 +0100 Subject: [PATCH 31/78] ... --- modules/actions/artifacts.go | 2 +- routers/api/actions/artifactsv4.go | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/modules/actions/artifacts.go b/modules/actions/artifacts.go index 56a3a14a8ec73..01d8376f22e10 100644 --- a/modules/actions/artifacts.go +++ b/modules/actions/artifacts.go @@ -26,7 +26,7 @@ func DownloadArtifactV4ServeDirectOnly(ctx *context.Base, art *actions_model.Act reqParams := url.Values{} reqParams.Set("response-content-type", art.ContentEncoding) reqParams.Set("response-content-disposition", fmt.Sprintf("inline; filename=%s; filename*=UTF-8''%s", url.PathEscape(art.ArtifactPath), art.ArtifactPath)) - u, err := storage.ActionsArtifacts.URL(art.StoragePath, art.ArtifactPath, ctx.Req.Method, nil) + u, err := storage.ActionsArtifacts.URL(art.StoragePath, art.ArtifactPath, ctx.Req.Method, reqParams) if u != nil && err == nil { ctx.Redirect(u.String(), http.StatusFound) return true, nil diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index 4366e28b29519..ddcc77eb76865 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -573,7 +573,7 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) { reqParams := url.Values{} reqParams.Set("response-content-type", artifact.ContentEncoding) reqParams.Set("response-content-disposition", fmt.Sprintf("inline; filename=%s; filename*=UTF-8''%s", url.PathEscape(artifact.ArtifactPath), artifact.ArtifactPath)) - u, err := storage.ActionsArtifacts.URL(artifact.StoragePath, artifact.ArtifactPath, ctx.Req.Method, reqParams) + u, err := storage.ActionsArtifacts.URL(artifact.StoragePath, artifact.ArtifactPath, http.MethodGet, reqParams) if u != nil && err == nil { respData.SignedUrl = u.String() } @@ -603,12 +603,17 @@ func (r *artifactV4Routes) downloadArtifact(ctx *ArtifactContext) { return } + file, err := r.fs.Open(artifact.StoragePath) + if err != nil { + log.Error("Error artifact not found: %v", err) + ctx.HTTPError(http.StatusNotFound, "Error artifact not found") + } + defer func() { _ = file.Close() }() + ctx.Resp.Header().Set("Content-Type", artifact.ContentEncoding) ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%s; filename*=UTF-8''%s", url.PathEscape(artifact.ArtifactPath), artifact.ArtifactPath)) ctx.Resp.Header().Set("Content-Security-Policy", "sandbox; default-src 'none';") - file, _ := r.fs.Open(artifact.StoragePath) - _, _ = io.Copy(ctx.Resp, file) } From 8c3590b97e9d6f265b570f341be842ac58352c0f Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Mon, 9 Mar 2026 20:52:16 +0100 Subject: [PATCH 32/78] refactor --- modules/actions/artifacts.go | 50 ++++++++++++++++++++++----- routers/api/actions/artifactsv4.go | 54 +++++++++++++----------------- routers/api/v1/repo/action.go | 2 -- routers/web/repo/actions/view.go | 2 -- 4 files changed, 65 insertions(+), 43 deletions(-) diff --git a/modules/actions/artifacts.go b/modules/actions/artifacts.go index 01d8376f22e10..1403df68fc006 100644 --- a/modules/actions/artifacts.go +++ b/modules/actions/artifacts.go @@ -4,11 +4,13 @@ package actions import ( - "fmt" + "errors" + "mime" "net/http" "net/url" "strings" + "code.gitea.io/gitea/models/actions" actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" @@ -21,14 +23,38 @@ func IsArtifactV4(art *actions_model.ActionArtifact) bool { return strings.Contains(art.ContentEncoding, "/") } +func GetArtifactContentTypeAndDisposition(artifact *actions.ActionArtifact) (contentType, contentDisposition string, _ error) { + contentType = mime.FormatMediaType(artifact.ContentEncoding, nil) + contentDisposition = mime.FormatMediaType("inline", map[string]string{ + "filename": artifact.ArtifactPath, + }) + if contentType == "" || contentDisposition == "" { + setting.PanicInDevOrTesting("cannot generate mime headers") + return "", "", errors.New("cannot generate mime headers") + } + return contentType, contentDisposition, nil +} + +func GetArtifactV4ServeDirectURL(ctx *context.Base, art *actions_model.ActionArtifact) (string, error) { + contentType, contentDisposition, err := GetArtifactContentTypeAndDisposition(art) + if err != nil { + return "", err + } + reqParams := url.Values{} + reqParams.Set("response-content-type", contentType) + reqParams.Set("response-content-disposition", contentDisposition) + u, err := storage.ActionsArtifacts.URL(art.StoragePath, art.ArtifactPath, ctx.Req.Method, reqParams) + if u != nil && err == nil { + return u.String(), nil + } + return "", nil +} + func DownloadArtifactV4ServeDirectOnly(ctx *context.Base, art *actions_model.ActionArtifact) (bool, error) { if setting.Actions.ArtifactStorage.ServeDirect() { - reqParams := url.Values{} - reqParams.Set("response-content-type", art.ContentEncoding) - reqParams.Set("response-content-disposition", fmt.Sprintf("inline; filename=%s; filename*=UTF-8''%s", url.PathEscape(art.ArtifactPath), art.ArtifactPath)) - u, err := storage.ActionsArtifacts.URL(art.StoragePath, art.ArtifactPath, ctx.Req.Method, reqParams) - if u != nil && err == nil { - ctx.Redirect(u.String(), http.StatusFound) + u, err := GetArtifactV4ServeDirectURL(ctx, art) + if u != "" && err == nil { + ctx.Redirect(u, http.StatusFound) return true, nil } } @@ -41,7 +67,15 @@ func DownloadArtifactV4Fallback(ctx *context.Base, art *actions_model.ActionArti return err } defer f.Close() - ctx.Resp.Header().Set("Content-Type", art.ContentEncoding) + + contentType, contentDisposition, err := GetArtifactContentTypeAndDisposition(art) + if err != nil { + return err + } + + ctx.Resp.Header().Set("Content-Type", contentType) + ctx.Resp.Header().Set("Content-Disposition", contentDisposition) + ctx.Resp.Header().Set("Content-Security-Policy", "sandbox; style-src 'unsafe-inline'; default-src 'none';") http.ServeContent(ctx.Resp, ctx.Req, art.ArtifactPath, art.CreatedUnix.AsLocalTime(), f) return nil } diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index ddcc77eb76865..f4cf2306184fd 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -93,6 +93,7 @@ import ( "errors" "fmt" "io" + "mime" "net/http" "net/url" "path" @@ -100,8 +101,9 @@ import ( "strings" "time" - "code.gitea.io/gitea/models/actions" + actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/actions" "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -220,7 +222,7 @@ func parseChunkFileItemV4(st storage.ObjectStorage, artifactID int64, fpath stri return &item, nil } -func (r *artifactV4Routes) verifySignature(ctx *ArtifactContext, endp string) (*actions.ActionTask, string, bool) { +func (r *artifactV4Routes) verifySignature(ctx *ArtifactContext, endp string) (*actions_model.ActionTask, string, bool) { rawTaskID := ctx.Req.URL.Query().Get("taskID") rawArtifactID := ctx.Req.URL.Query().Get("artifactID") sig := ctx.Req.URL.Query().Get("sig") @@ -247,13 +249,13 @@ func (r *artifactV4Routes) verifySignature(ctx *ArtifactContext, endp string) (* ctx.HTTPError(http.StatusUnauthorized, "Error link expired") return nil, "", false } - task, err := actions.GetTaskByID(ctx, taskID) + task, err := actions_model.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 { + if task.Status != actions_model.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 @@ -266,8 +268,8 @@ func (r *artifactV4Routes) verifySignature(ctx *ArtifactContext, endp string) (* return task, artifactName, true } -func (r *artifactV4Routes) getArtifactByName(ctx *ArtifactContext, runID int64, name string) (*actions.ActionArtifact, error) { - var art actions.ActionArtifact +func (r *artifactV4Routes) getArtifactByName(ctx *ArtifactContext, runID int64, name string) (*actions_model.ActionArtifact, error) { + var art actions_model.ActionArtifact has, err := db.GetEngine(ctx).Where(builder.Eq{"run_id": runID, "artifact_name": name}, builder.Like{"content_encoding", "/"}).Get(&art) if err != nil { return nil, err @@ -300,7 +302,7 @@ func (r *artifactV4Routes) sendProtobufBody(ctx *ArtifactContext, req protorefle ctx.HTTPError(http.StatusInternalServerError, "Error encode response body") return } - ctx.Resp.Header().Set("Content-Type", "application/json;charset=utf-8") + ctx.Resp.Header().Set("Content-Type", mime.FormatMediaType("application/json", map[string]string{"charset": "utf-8"})) ctx.Resp.WriteHeader(http.StatusOK) _, _ = ctx.Resp.Write(resp) } @@ -329,7 +331,7 @@ func (r *artifactV4Routes) createArtifact(ctx *ArtifactContext) { fileName = artifactName + ".zip" } // create or get artifact with name and path - artifact, err := actions.CreateArtifact(ctx, ctx.ActionTask, artifactName, fileName, retentionDays) + artifact, err := actions_model.CreateArtifact(ctx, ctx.ActionTask, artifactName, fileName, retentionDays) if err != nil { log.Error("Error create or get artifact: %v", err) ctx.HTTPError(http.StatusInternalServerError, "Error create or get artifact") @@ -338,7 +340,7 @@ func (r *artifactV4Routes) createArtifact(ctx *ArtifactContext) { artifact.ContentEncoding = encoding artifact.FileSize = 0 artifact.FileCompressedSize = 0 - if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil { + if err := actions_model.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil { log.Error("Error UpdateArtifactByID: %v", err) ctx.HTTPError(http.StatusInternalServerError, "Error UpdateArtifactByID") return @@ -377,7 +379,7 @@ func (r *artifactV4Routes) uploadArtifact(ctx *ArtifactContext) { } artifact.FileCompressedSize += uploadedLength artifact.FileSize += uploadedLength - if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil { + if err := actions_model.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil { log.Error("Error UpdateArtifactByID: %v", err) ctx.HTTPError(http.StatusInternalServerError, "Error UpdateArtifactByID") return @@ -500,9 +502,9 @@ func (r *artifactV4Routes) listArtifacts(ctx *ArtifactContext) { return } - artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{ + artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{ RunID: runID, - Status: int(actions.ArtifactStatusUploadConfirmed), + Status: int(actions_model.ArtifactStatusUploadConfirmed), FinalizedArtifactsV4: true, }) if err != nil { @@ -561,7 +563,7 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) { ctx.HTTPError(http.StatusNotFound, "Error artifact not found") return } - if artifact.Status != actions.ArtifactStatusUploadConfirmed { + if artifact.Status != actions_model.ArtifactStatusUploadConfirmed { log.Error("Error artifact not found: %s", artifact.Status.ToString()) ctx.HTTPError(http.StatusNotFound, "Error artifact not found") return @@ -570,12 +572,9 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) { respData := GetSignedArtifactURLResponse{} if setting.Actions.ArtifactStorage.ServeDirect() { - reqParams := url.Values{} - reqParams.Set("response-content-type", artifact.ContentEncoding) - reqParams.Set("response-content-disposition", fmt.Sprintf("inline; filename=%s; filename*=UTF-8''%s", url.PathEscape(artifact.ArtifactPath), artifact.ArtifactPath)) - u, err := storage.ActionsArtifacts.URL(artifact.StoragePath, artifact.ArtifactPath, http.MethodGet, reqParams) - if u != nil && err == nil { - respData.SignedUrl = u.String() + u, err := actions.GetArtifactV4ServeDirectURL(ctx.Base, artifact) + if u != "" && err == nil { + respData.SignedUrl = u } } if respData.SignedUrl == "" { @@ -597,24 +596,17 @@ func (r *artifactV4Routes) downloadArtifact(ctx *ArtifactContext) { ctx.HTTPError(http.StatusNotFound, "Error artifact not found") return } - if artifact.Status != actions.ArtifactStatusUploadConfirmed { + if artifact.Status != actions_model.ArtifactStatusUploadConfirmed { log.Error("Error artifact not found: %s", artifact.Status.ToString()) ctx.HTTPError(http.StatusNotFound, "Error artifact not found") return } - file, err := r.fs.Open(artifact.StoragePath) + err = actions.DownloadArtifactV4Fallback(ctx.Base, artifact) if err != nil { - log.Error("Error artifact not found: %v", err) - ctx.HTTPError(http.StatusNotFound, "Error artifact not found") + log.Error("Error serve artifact: %v", err) + ctx.HTTPError(http.StatusInternalServerError, err.Error()) } - defer func() { _ = file.Close() }() - - ctx.Resp.Header().Set("Content-Type", artifact.ContentEncoding) - ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%s; filename*=UTF-8''%s", url.PathEscape(artifact.ArtifactPath), artifact.ArtifactPath)) - ctx.Resp.Header().Set("Content-Security-Policy", "sandbox; default-src 'none';") - - _, _ = io.Copy(ctx.Resp, file) } func (r *artifactV4Routes) deleteArtifact(ctx *ArtifactContext) { @@ -636,7 +628,7 @@ func (r *artifactV4Routes) deleteArtifact(ctx *ArtifactContext) { return } - err = actions.SetArtifactNeedDelete(ctx, runID, req.Name) + err = actions_model.SetArtifactNeedDelete(ctx, runID, req.Name) if err != nil { log.Error("Error deleting artifacts: %v", err) ctx.HTTPError(http.StatusInternalServerError, err.Error()) diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 6f4d5d35727bb..34745c66bb5b4 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -1801,8 +1801,6 @@ func DownloadArtifactRaw(ctx *context.APIContext) { ctx.APIError(http.StatusNotFound, "Artifact has expired") return } - ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip; filename*=UTF-8''%s.zip", url.PathEscape(art.ArtifactName), art.ArtifactName)) - if actions.IsArtifactV4(art) { err := actions.DownloadArtifactV4(ctx.Base, art) if err != nil { diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 361dcb8b1a5c1..2b5da065515f9 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -694,8 +694,6 @@ func ArtifactsDownloadView(ctx *context_module.Context) { } if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) { - ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%s; filename*=UTF-8''%s", url.PathEscape(artifacts[0].ArtifactPath), artifacts[0].ArtifactPath)) - ctx.Resp.Header().Set("Content-Security-Policy", "sandbox; default-src 'none';") err := actions.DownloadArtifactV4(ctx.Base, artifacts[0]) if err != nil { ctx.ServerError("DownloadArtifactV4", err) From 744bb71ce3001afc0cf9f88b7626442fb989481f Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Mon, 9 Mar 2026 21:13:16 +0100 Subject: [PATCH 33/78] Reenable ServeDirect for minio --- .../api_actions_artifact_v4_test.go | 39 +++++++++++++------ 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/tests/integration/api_actions_artifact_v4_test.go b/tests/integration/api_actions_artifact_v4_test.go index 3d341d2fd10ae..b0c4f9b3f30ab 100644 --- a/tests/integration/api_actions_artifact_v4_test.go +++ b/tests/integration/api_actions_artifact_v4_test.go @@ -30,6 +30,7 @@ import ( actions_service "code.gitea.io/gitea/services/actions" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/reflect/protoreflect" "google.golang.org/protobuf/types/known/timestamppb" @@ -411,15 +412,15 @@ func TestActionsArtifactV4DownloadSingle(t *testing.T) { ServeDirect bool }{ {Name: "Download"}, - // FIXME ServeDirect Content-Type and Content-Disposition are partially broken in minio and not implemented azure - // {Name: "ServeDirect", ServeDirect: true}, + {Name: "ServeDirect", ServeDirect: true}, } for _, entry := range table { t.Run(entry.Name, func(t *testing.T) { switch setting.Actions.ArtifactStorage.Type { - case setting.AzureBlobStorageType: - defer test.MockVariableValue(&setting.Actions.ArtifactStorage.AzureBlobConfig.ServeDirect, entry.ServeDirect)() + // FIXME ServeDirect Content-Type and Content-Disposition are partially broken in minio and not implemented in azure + // case setting.AzureBlobStorageType: + // defer test.MockVariableValue(&setting.Actions.ArtifactStorage.AzureBlobConfig.ServeDirect, entry.ServeDirect)() case setting.MinioStorageType: defer test.MockVariableValue(&setting.Actions.ArtifactStorage.MinioConfig.ServeDirect, entry.ServeDirect)() default: @@ -451,14 +452,30 @@ func TestActionsArtifactV4DownloadSingle(t *testing.T) { protojson.Unmarshal(resp.Body.Bytes(), &finalizeResp) assert.NotEmpty(t, finalizeResp.SignedUrl) - // FIXME use real http client if ServeDirect is true - req = NewRequest(t, "GET", finalizeResp.SignedUrl) - resp = MakeRequest(t, req, http.StatusOK) - // TODO add test data for other file types - assert.Equal(t, actions.ArtifactV4ContentEncoding, resp.Header().Get("Content-Type")) - // TODO add a CSP test body := strings.Repeat("D", 1024) - assert.Equal(t, body, resp.Body.String()) + // FIXME use real http client if ServeDirect is true + if entry.ServeDirect { + externalReq, err := http.NewRequestWithContext(t.Context(), http.MethodGet, finalizeResp.SignedUrl, nil) + require.NoError(t, err) + externalResp, err := http.DefaultClient.Do(externalReq) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, externalResp.StatusCode) + // FIXME add test data for other file types + assert.Equal(t, actions.ArtifactV4ContentEncoding, externalResp.Header.Get("Content-Type")) + // FIXME Content-Type-Disposition Check + buf := make([]byte, 1024) + n, err := io.ReadAtLeast(externalResp.Body, buf, len(buf)) + require.NoError(t, err) + assert.Equal(t, len(buf), n) + assert.Equal(t, body, string(buf)) + } else { + req = NewRequest(t, "GET", finalizeResp.SignedUrl) + resp = MakeRequest(t, req, http.StatusOK) + // FIXME add test data for other file types + assert.Equal(t, actions.ArtifactV4ContentEncoding, resp.Header().Get("Content-Type")) + // FIXME Content-Type-Disposition Check + assert.Equal(t, body, resp.Body.String()) + } }) } } From 2994294576c99ce14b6cdb2191b1af5683c64921 Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Mon, 9 Mar 2026 21:16:23 +0100 Subject: [PATCH 34/78] update comments --- modules/actions/artifacts.go | 1 + tests/integration/api_actions_artifact_v4_test.go | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/actions/artifacts.go b/modules/actions/artifacts.go index 1403df68fc006..c4d079d8300f7 100644 --- a/modules/actions/artifacts.go +++ b/modules/actions/artifacts.go @@ -40,6 +40,7 @@ func GetArtifactV4ServeDirectURL(ctx *context.Base, art *actions_model.ActionArt if err != nil { return "", err } + // FIXME not working for azure, partially working for minio reqParams := url.Values{} reqParams.Set("response-content-type", contentType) reqParams.Set("response-content-disposition", contentDisposition) diff --git a/tests/integration/api_actions_artifact_v4_test.go b/tests/integration/api_actions_artifact_v4_test.go index b0c4f9b3f30ab..1a6c50d7073c1 100644 --- a/tests/integration/api_actions_artifact_v4_test.go +++ b/tests/integration/api_actions_artifact_v4_test.go @@ -453,7 +453,6 @@ func TestActionsArtifactV4DownloadSingle(t *testing.T) { assert.NotEmpty(t, finalizeResp.SignedUrl) body := strings.Repeat("D", 1024) - // FIXME use real http client if ServeDirect is true if entry.ServeDirect { externalReq, err := http.NewRequestWithContext(t.Context(), http.MethodGet, finalizeResp.SignedUrl, nil) require.NoError(t, err) From deae805d916b30a2d6d5a9df73ef8dd99a868c95 Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Mon, 9 Mar 2026 21:41:49 +0100 Subject: [PATCH 35/78] fix double import --- modules/actions/artifacts.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/modules/actions/artifacts.go b/modules/actions/artifacts.go index c4d079d8300f7..833e1d07e3355 100644 --- a/modules/actions/artifacts.go +++ b/modules/actions/artifacts.go @@ -10,7 +10,6 @@ import ( "net/url" "strings" - "code.gitea.io/gitea/models/actions" actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" @@ -23,7 +22,7 @@ func IsArtifactV4(art *actions_model.ActionArtifact) bool { return strings.Contains(art.ContentEncoding, "/") } -func GetArtifactContentTypeAndDisposition(artifact *actions.ActionArtifact) (contentType, contentDisposition string, _ error) { +func GetArtifactContentTypeAndDisposition(artifact *actions_model.ActionArtifact) (contentType, contentDisposition string, _ error) { contentType = mime.FormatMediaType(artifact.ContentEncoding, nil) contentDisposition = mime.FormatMediaType("inline", map[string]string{ "filename": artifact.ArtifactPath, From 6666dff463d4f43b5cfbbdcd07fa3bae83398faa Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Mon, 9 Mar 2026 22:16:46 +0100 Subject: [PATCH 36/78] close body --- tests/integration/api_actions_artifact_v4_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/api_actions_artifact_v4_test.go b/tests/integration/api_actions_artifact_v4_test.go index 1a6c50d7073c1..913bf67698c43 100644 --- a/tests/integration/api_actions_artifact_v4_test.go +++ b/tests/integration/api_actions_artifact_v4_test.go @@ -464,6 +464,7 @@ func TestActionsArtifactV4DownloadSingle(t *testing.T) { // FIXME Content-Type-Disposition Check buf := make([]byte, 1024) n, err := io.ReadAtLeast(externalResp.Body, buf, len(buf)) + externalResp.Body.Close() require.NoError(t, err) assert.Equal(t, len(buf), n) assert.Equal(t, body, string(buf)) From dcbe0d663c703b0ee33995a5133858d9d3b4d01a Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Mon, 9 Mar 2026 23:25:27 +0100 Subject: [PATCH 37/78] cleanup --- routers/api/v1/repo/action.go | 1 - tests/integration/api_actions_artifact_v4_test.go | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 34745c66bb5b4..d466d4ad05bf4 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -1743,7 +1743,6 @@ func DownloadArtifact(ctx *context.APIContext) { ctx.APIError(http.StatusNotFound, "Artifact has expired") return } - ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip; filename*=UTF-8''%s.zip", url.PathEscape(art.ArtifactName), art.ArtifactName)) if actions.IsArtifactV4(art) { ok, err := actions.DownloadArtifactV4ServeDirectOnly(ctx.Base, art) diff --git a/tests/integration/api_actions_artifact_v4_test.go b/tests/integration/api_actions_artifact_v4_test.go index 913bf67698c43..16dd06456a594 100644 --- a/tests/integration/api_actions_artifact_v4_test.go +++ b/tests/integration/api_actions_artifact_v4_test.go @@ -461,7 +461,7 @@ func TestActionsArtifactV4DownloadSingle(t *testing.T) { assert.Equal(t, http.StatusOK, externalResp.StatusCode) // FIXME add test data for other file types assert.Equal(t, actions.ArtifactV4ContentEncoding, externalResp.Header.Get("Content-Type")) - // FIXME Content-Type-Disposition Check + // FIXME Content-Disposition Check buf := make([]byte, 1024) n, err := io.ReadAtLeast(externalResp.Body, buf, len(buf)) externalResp.Body.Close() From 8ba37a2c34d75745c926d3d9181bea97345b6c85 Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Tue, 10 Mar 2026 21:57:13 +0100 Subject: [PATCH 38/78] GetArtifactV4ServeDirectURL add method param --- modules/actions/artifacts.go | 6 +++--- routers/api/actions/artifactsv4.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/actions/artifacts.go b/modules/actions/artifacts.go index 833e1d07e3355..ddd9289fa2b37 100644 --- a/modules/actions/artifacts.go +++ b/modules/actions/artifacts.go @@ -34,7 +34,7 @@ func GetArtifactContentTypeAndDisposition(artifact *actions_model.ActionArtifact return contentType, contentDisposition, nil } -func GetArtifactV4ServeDirectURL(ctx *context.Base, art *actions_model.ActionArtifact) (string, error) { +func GetArtifactV4ServeDirectURL(ctx *context.Base, art *actions_model.ActionArtifact, method string) (string, error) { contentType, contentDisposition, err := GetArtifactContentTypeAndDisposition(art) if err != nil { return "", err @@ -43,7 +43,7 @@ func GetArtifactV4ServeDirectURL(ctx *context.Base, art *actions_model.ActionArt reqParams := url.Values{} reqParams.Set("response-content-type", contentType) reqParams.Set("response-content-disposition", contentDisposition) - u, err := storage.ActionsArtifacts.URL(art.StoragePath, art.ArtifactPath, ctx.Req.Method, reqParams) + u, err := storage.ActionsArtifacts.URL(art.StoragePath, art.ArtifactPath, method, reqParams) if u != nil && err == nil { return u.String(), nil } @@ -52,7 +52,7 @@ func GetArtifactV4ServeDirectURL(ctx *context.Base, art *actions_model.ActionArt func DownloadArtifactV4ServeDirectOnly(ctx *context.Base, art *actions_model.ActionArtifact) (bool, error) { if setting.Actions.ArtifactStorage.ServeDirect() { - u, err := GetArtifactV4ServeDirectURL(ctx, art) + u, err := GetArtifactV4ServeDirectURL(ctx, art, ctx.Req.Method) if u != "" && err == nil { ctx.Redirect(u, http.StatusFound) return true, nil diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index f4cf2306184fd..5fa62233f40eb 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -572,7 +572,7 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) { respData := GetSignedArtifactURLResponse{} if setting.Actions.ArtifactStorage.ServeDirect() { - u, err := actions.GetArtifactV4ServeDirectURL(ctx.Base, artifact) + u, err := actions.GetArtifactV4ServeDirectURL(ctx.Base, artifact, http.MethodGet) if u != "" && err == nil { respData.SignedUrl = u } From 9c2528efdd5a2d6ad83fdb4c8f7b054f5ed5254f Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Tue, 10 Mar 2026 22:10:24 +0100 Subject: [PATCH 39/78] application/pdf skip Content-Security-Policy --- modules/actions/artifacts.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/modules/actions/artifacts.go b/modules/actions/artifacts.go index ddd9289fa2b37..edfb34e4ddf4e 100644 --- a/modules/actions/artifacts.go +++ b/modules/actions/artifacts.go @@ -23,6 +23,7 @@ func IsArtifactV4(art *actions_model.ActionArtifact) bool { } func GetArtifactContentTypeAndDisposition(artifact *actions_model.ActionArtifact) (contentType, contentDisposition string, _ error) { + // FIXME check if contentType is safe or application/html? contentType = mime.FormatMediaType(artifact.ContentEncoding, nil) contentDisposition = mime.FormatMediaType("inline", map[string]string{ "filename": artifact.ArtifactPath, @@ -75,7 +76,10 @@ func DownloadArtifactV4Fallback(ctx *context.Base, art *actions_model.ActionArti ctx.Resp.Header().Set("Content-Type", contentType) ctx.Resp.Header().Set("Content-Disposition", contentDisposition) - ctx.Resp.Header().Set("Content-Security-Policy", "sandbox; style-src 'unsafe-inline'; default-src 'none';") + // HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context, it seems fine to render it inline + if mediaType, _, err := mime.ParseMediaType(contentType); err != nil || mediaType != "application/pdf" { + ctx.Resp.Header().Set("Content-Security-Policy", "sandbox; style-src 'unsafe-inline'; default-src 'none';") + } http.ServeContent(ctx.Resp, ctx.Req, art.ArtifactPath, art.CreatedUnix.AsLocalTime(), f) return nil } From 89af55d968f32a2a7bb4741c92c02a695f8fc5e3 Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Tue, 10 Mar 2026 22:18:33 +0100 Subject: [PATCH 40/78] Only use inline for web route --- modules/actions/artifacts.go | 29 ++++++++++++++++++----------- routers/api/actions/artifactsv4.go | 4 ++-- routers/api/v1/repo/action.go | 4 ++-- routers/web/repo/actions/view.go | 2 +- 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/modules/actions/artifacts.go b/modules/actions/artifacts.go index edfb34e4ddf4e..d3ceadef47b5c 100644 --- a/modules/actions/artifacts.go +++ b/modules/actions/artifacts.go @@ -22,10 +22,17 @@ func IsArtifactV4(art *actions_model.ActionArtifact) bool { return strings.Contains(art.ContentEncoding, "/") } -func GetArtifactContentTypeAndDisposition(artifact *actions_model.ActionArtifact) (contentType, contentDisposition string, _ error) { +type ContentDispositionType string + +const ( + ContentDispositionInline ContentDispositionType = "inline" + ContentDispositionAttachment ContentDispositionType = "attachment" +) + +func GetArtifactContentTypeAndDisposition(artifact *actions_model.ActionArtifact, cdt ContentDispositionType) (contentType, contentDisposition string, _ error) { // FIXME check if contentType is safe or application/html? contentType = mime.FormatMediaType(artifact.ContentEncoding, nil) - contentDisposition = mime.FormatMediaType("inline", map[string]string{ + contentDisposition = mime.FormatMediaType(string(cdt), map[string]string{ "filename": artifact.ArtifactPath, }) if contentType == "" || contentDisposition == "" { @@ -35,8 +42,8 @@ func GetArtifactContentTypeAndDisposition(artifact *actions_model.ActionArtifact return contentType, contentDisposition, nil } -func GetArtifactV4ServeDirectURL(ctx *context.Base, art *actions_model.ActionArtifact, method string) (string, error) { - contentType, contentDisposition, err := GetArtifactContentTypeAndDisposition(art) +func GetArtifactV4ServeDirectURL(ctx *context.Base, art *actions_model.ActionArtifact, method string, cdt ContentDispositionType) (string, error) { + contentType, contentDisposition, err := GetArtifactContentTypeAndDisposition(art, cdt) if err != nil { return "", err } @@ -51,9 +58,9 @@ func GetArtifactV4ServeDirectURL(ctx *context.Base, art *actions_model.ActionArt return "", nil } -func DownloadArtifactV4ServeDirectOnly(ctx *context.Base, art *actions_model.ActionArtifact) (bool, error) { +func DownloadArtifactV4ServeDirectOnly(ctx *context.Base, art *actions_model.ActionArtifact, cdt ContentDispositionType) (bool, error) { if setting.Actions.ArtifactStorage.ServeDirect() { - u, err := GetArtifactV4ServeDirectURL(ctx, art, ctx.Req.Method) + u, err := GetArtifactV4ServeDirectURL(ctx, art, ctx.Req.Method, cdt) if u != "" && err == nil { ctx.Redirect(u, http.StatusFound) return true, nil @@ -62,14 +69,14 @@ func DownloadArtifactV4ServeDirectOnly(ctx *context.Base, art *actions_model.Act return false, nil } -func DownloadArtifactV4Fallback(ctx *context.Base, art *actions_model.ActionArtifact) error { +func DownloadArtifactV4Fallback(ctx *context.Base, art *actions_model.ActionArtifact, cdt ContentDispositionType) error { f, err := storage.ActionsArtifacts.Open(art.StoragePath) if err != nil { return err } defer f.Close() - contentType, contentDisposition, err := GetArtifactContentTypeAndDisposition(art) + contentType, contentDisposition, err := GetArtifactContentTypeAndDisposition(art, cdt) if err != nil { return err } @@ -84,10 +91,10 @@ func DownloadArtifactV4Fallback(ctx *context.Base, art *actions_model.ActionArti return nil } -func DownloadArtifactV4(ctx *context.Base, art *actions_model.ActionArtifact) error { - ok, err := DownloadArtifactV4ServeDirectOnly(ctx, art) +func DownloadArtifactV4(ctx *context.Base, art *actions_model.ActionArtifact, cdt ContentDispositionType) error { + ok, err := DownloadArtifactV4ServeDirectOnly(ctx, art, cdt) if ok || err != nil { return err } - return DownloadArtifactV4Fallback(ctx, art) + return DownloadArtifactV4Fallback(ctx, art, cdt) } diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index 5fa62233f40eb..b5e4b24fa806b 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -572,7 +572,7 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) { respData := GetSignedArtifactURLResponse{} if setting.Actions.ArtifactStorage.ServeDirect() { - u, err := actions.GetArtifactV4ServeDirectURL(ctx.Base, artifact, http.MethodGet) + u, err := actions.GetArtifactV4ServeDirectURL(ctx.Base, artifact, http.MethodGet, actions.ContentDispositionAttachment) if u != "" && err == nil { respData.SignedUrl = u } @@ -602,7 +602,7 @@ func (r *artifactV4Routes) downloadArtifact(ctx *ArtifactContext) { return } - err = actions.DownloadArtifactV4Fallback(ctx.Base, artifact) + err = actions.DownloadArtifactV4Fallback(ctx.Base, artifact, actions.ContentDispositionAttachment) if err != nil { log.Error("Error serve artifact: %v", err) ctx.HTTPError(http.StatusInternalServerError, err.Error()) diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index d466d4ad05bf4..daf0b49afe228 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -1745,7 +1745,7 @@ func DownloadArtifact(ctx *context.APIContext) { } if actions.IsArtifactV4(art) { - ok, err := actions.DownloadArtifactV4ServeDirectOnly(ctx.Base, art) + ok, err := actions.DownloadArtifactV4ServeDirectOnly(ctx.Base, art, actions.ContentDispositionAttachment) if ok { return } @@ -1801,7 +1801,7 @@ func DownloadArtifactRaw(ctx *context.APIContext) { return } if actions.IsArtifactV4(art) { - err := actions.DownloadArtifactV4(ctx.Base, art) + err := actions.DownloadArtifactV4(ctx.Base, art, "attachment") if err != nil { ctx.APIErrorInternal(err) return diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 2b5da065515f9..dc68e0d888833 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -694,7 +694,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) { } if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) { - err := actions.DownloadArtifactV4(ctx.Base, artifacts[0]) + err := actions.DownloadArtifactV4(ctx.Base, artifacts[0], actions.ContentDispositionInline) if err != nil { ctx.ServerError("DownloadArtifactV4", err) return From b6a1ba67835cab4cde675f3a898d90544a4752ca Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Tue, 10 Mar 2026 22:32:37 +0100 Subject: [PATCH 41/78] add download tests other file types internal api --- models/fixtures/action_artifact.yml | 36 ++++++++++++++++++ .../api_actions_artifact_v4_test.go | 38 +++++++++++++------ 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/models/fixtures/action_artifact.yml b/models/fixtures/action_artifact.yml index ee8ef0d5cec48..a25dfc205c45a 100644 --- a/models/fixtures/action_artifact.yml +++ b/models/fixtures/action_artifact.yml @@ -141,3 +141,39 @@ created_unix: 1730330775 updated_unix: 1730330775 expired_unix: 1738106775 + +- + id: 26 + run_id: 792 + runner_id: 1 + repo_id: 4 + owner_id: 1 + commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0 + storage_path: "27/5/1730330775594233150.chunk" + file_size: 1024 + file_compressed_size: 1024 + content_encoding: "application/pdf" + artifact_path: "report.pdf" + artifact_name: "report.pdf" + status: 2 + created_unix: 1730330775 + updated_unix: 1730330775 + expired_unix: 1738106775 + +- + id: 27 + run_id: 792 + runner_id: 1 + repo_id: 4 + owner_id: 1 + commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0 + storage_path: "27/5/1730330775594233150.chunk" + file_size: 1024 + file_compressed_size: 1024 + content_encoding: "application/html" + artifact_path: "report.html" + artifact_name: "report.html" + status: 2 + created_unix: 1730330775 + updated_unix: 1730330775 + expired_unix: 1738106775 diff --git a/tests/integration/api_actions_artifact_v4_test.go b/tests/integration/api_actions_artifact_v4_test.go index 16dd06456a594..1f11b96e0e408 100644 --- a/tests/integration/api_actions_artifact_v4_test.go +++ b/tests/integration/api_actions_artifact_v4_test.go @@ -408,11 +408,17 @@ func TestActionsArtifactV4DownloadSingle(t *testing.T) { assert.NoError(t, err) table := []struct { - Name string - ServeDirect bool + Name string + ArtifactName string + ServeDirect bool + ContentType string }{ - {Name: "Download"}, - {Name: "ServeDirect", ServeDirect: true}, + {Name: "Download-Zip", ArtifactName: "artifact-v4-download", ContentType: actions.ArtifactV4ContentEncoding}, + {Name: "Download-Pdf", ArtifactName: "report.pdf", ContentType: "application/pdf"}, + {Name: "Download-Html", ArtifactName: "report.html", ContentType: "application/html"}, + {Name: "ServeDirect-Zip", ArtifactName: "artifact-v4-download", ContentType: actions.ArtifactV4ContentEncoding, ServeDirect: true}, + {Name: "ServeDirect-Pdf", ArtifactName: "report.pdf", ContentType: "application/pdf", ServeDirect: true}, + {Name: "ServeDirect-Html", ArtifactName: "report.html", ContentType: "application/html", ServeDirect: true}, } for _, entry := range table { @@ -431,25 +437,35 @@ func TestActionsArtifactV4DownloadSingle(t *testing.T) { // list artifacts by name req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/ListArtifacts", toProtoJSON(&actions.ListArtifactsRequest{ - NameFilter: wrapperspb.String("artifact-v4-download"), + NameFilter: wrapperspb.String(entry.ArtifactName), WorkflowRunBackendId: "792", WorkflowJobRunBackendId: "193", })).AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) var listResp actions.ListArtifactsResponse - protojson.Unmarshal(resp.Body.Bytes(), &listResp) + require.NoError(t, protojson.Unmarshal(resp.Body.Bytes(), &listResp)) + require.Len(t, listResp.Artifacts, 1) + + // list artifacts by id + req = NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/ListArtifacts", toProtoJSON(&actions.ListArtifactsRequest{ + IdFilter: wrapperspb.Int64(listResp.Artifacts[0].DatabaseId), + WorkflowRunBackendId: "792", + WorkflowJobRunBackendId: "193", + })).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + require.NoError(t, protojson.Unmarshal(resp.Body.Bytes(), &listResp)) assert.Len(t, listResp.Artifacts, 1) // acquire artifact download url req = NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/GetSignedArtifactURL", toProtoJSON(&actions.GetSignedArtifactURLRequest{ - Name: "artifact-v4-download", + Name: entry.ArtifactName, WorkflowRunBackendId: "792", WorkflowJobRunBackendId: "193", })). AddTokenAuth(token) resp = MakeRequest(t, req, http.StatusOK) var finalizeResp actions.GetSignedArtifactURLResponse - protojson.Unmarshal(resp.Body.Bytes(), &finalizeResp) + require.NoError(t, protojson.Unmarshal(resp.Body.Bytes(), &finalizeResp)) assert.NotEmpty(t, finalizeResp.SignedUrl) body := strings.Repeat("D", 1024) @@ -459,8 +475,7 @@ func TestActionsArtifactV4DownloadSingle(t *testing.T) { externalResp, err := http.DefaultClient.Do(externalReq) require.NoError(t, err) assert.Equal(t, http.StatusOK, externalResp.StatusCode) - // FIXME add test data for other file types - assert.Equal(t, actions.ArtifactV4ContentEncoding, externalResp.Header.Get("Content-Type")) + assert.Equal(t, entry.ContentType, externalResp.Header.Get("Content-Type")) // FIXME Content-Disposition Check buf := make([]byte, 1024) n, err := io.ReadAtLeast(externalResp.Body, buf, len(buf)) @@ -471,8 +486,7 @@ func TestActionsArtifactV4DownloadSingle(t *testing.T) { } else { req = NewRequest(t, "GET", finalizeResp.SignedUrl) resp = MakeRequest(t, req, http.StatusOK) - // FIXME add test data for other file types - assert.Equal(t, actions.ArtifactV4ContentEncoding, resp.Header().Get("Content-Type")) + assert.Equal(t, entry.ContentType, resp.Header().Get("Content-Type")) // FIXME Content-Type-Disposition Check assert.Equal(t, body, resp.Body.String()) } From d1ac1dbfcbb57f39db2af8d6e31ac9855c4df412 Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Wed, 11 Mar 2026 01:00:51 +0100 Subject: [PATCH 42/78] Fix old test --- tests/integration/api_actions_artifact_v4_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/api_actions_artifact_v4_test.go b/tests/integration/api_actions_artifact_v4_test.go index 1f11b96e0e408..1f17cb89d2a8c 100644 --- a/tests/integration/api_actions_artifact_v4_test.go +++ b/tests/integration/api_actions_artifact_v4_test.go @@ -622,7 +622,7 @@ func TestActionsArtifactV4ListAndGetPublicApi(t *testing.T) { for _, artifact := range listResp.Entries { assert.Contains(t, artifact.URL, fmt.Sprintf("/api/v1/repos/%s/actions/artifacts/%d", repo.FullName(), artifact.ID)) assert.Contains(t, artifact.ArchiveDownloadURL, fmt.Sprintf("/api/v1/repos/%s/actions/artifacts/%d/zip", repo.FullName(), artifact.ID)) - req = NewRequestWithBody(t, "GET", listResp.Entries[0].URL, nil). + req = NewRequestWithBody(t, "GET", artifact.URL, nil). AddTokenAuth(token) resp = MakeRequest(t, req, http.StatusOK) From 8fd3a9aaa18023fadd7a95576504035f14b5b3fa Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sun, 22 Mar 2026 11:06:49 +0100 Subject: [PATCH 43/78] refac --- routers/api/actions/artifactsv4.go | 93 +++++--- .../api_actions_artifact_v4_test.go | 204 ++++++++++++------ 2 files changed, 202 insertions(+), 95 deletions(-) diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index 7711fe10d628a..7c11c9b795e32 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -89,6 +89,7 @@ import ( "crypto/hmac" "crypto/sha256" "encoding/base64" + "encoding/hex" "encoding/xml" "errors" "fmt" @@ -326,7 +327,7 @@ func (r *artifactV4Routes) createArtifact(ctx *ArtifactContext) { } encoding := req.GetMimeType().GetValue() fileName := artifactName - if !strings.Contains(encoding, "/") || req.GetVersion() < 7 { + if !strings.Contains(encoding, "/") { encoding = ArtifactV4ContentEncoding fileName = artifactName + ".zip" } @@ -340,16 +341,34 @@ func (r *artifactV4Routes) createArtifact(ctx *ArtifactContext) { artifact.ContentEncoding = encoding artifact.FileSize = 0 artifact.FileCompressedSize = 0 + + var respData CreateArtifactResponse + + if setting.Actions.ArtifactStorage.ServeDirect() && setting.Actions.ArtifactStorage.Type == setting.AzureBlobStorageType { + storagePath := fmt.Sprintf("%d/%d/%d.%s", artifact.RunID%255, artifact.ID%255, time.Now().UnixNano(), ".blob") + if artifact.StoragePath != "" { + _ = storage.ActionsArtifacts.Delete(artifact.StoragePath) + } + artifact.StoragePath = storagePath + artifact.Status = actions_model.ArtifactStatusUploadPending + u, _ := storage.ActionsArtifacts.ServeDirectURL(artifact.StoragePath, artifact.ArtifactPath, http.MethodPut, nil) + respData = CreateArtifactResponse{ + Ok: true, + SignedUploadUrl: u.String(), + } + } else { + respData = CreateArtifactResponse{ + Ok: true, + SignedUploadUrl: r.buildArtifactURL(ctx, "UploadArtifact", artifactName, ctx.ActionTask.ID, artifact.ID), + } + } + if err := actions_model.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.sendProtobufBody(ctx, &respData) } @@ -457,31 +476,53 @@ func (r *artifactV4Routes) finalizeArtifact(ctx *ArtifactContext) { return } - var chunks []*chunkFileItem - blockList, blockListErr := r.readBlockList(runID, artifact.ID) - chunks, err = listOrderedChunksForArtifact(r.fs, runID, artifact.ID, blockList) - if err != nil { - log.Error("Error list chunks: %v", errors.Join(blockListErr, err)) - ctx.HTTPError(http.StatusInternalServerError, "Error list chunks") - return - } - artifact.FileSize = chunks[len(chunks)-1].End + 1 - artifact.FileCompressedSize = chunks[len(chunks)-1].End + 1 - - if req.Size != artifact.FileSize { - log.Error("Error merge chunks size mismatch") - ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks size mismatch") - return - } - 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 + + if setting.Actions.ArtifactStorage.ServeDirect() && setting.Actions.ArtifactStorage.Type == setting.AzureBlobStorageType { + hashSha256 := sha256.New() + obj, err := storage.ActionsArtifacts.Open(artifact.StoragePath) + if err != nil { + log.Error("Error read block: %v", err) + ctx.HTTPError(http.StatusInternalServerError, "Error read block") + return + } + defer obj.Close() + n, _ := io.Copy(hashSha256, obj) + rawChecksum := hashSha256.Sum(nil) + actualChecksum := hex.EncodeToString(rawChecksum) + if checksum != "sha256:"+actualChecksum { + log.Error("Error merge chunks: checksum mismatch") + ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks: checksum mismatch") + return + } + _ = n + } else { + + var chunks []*chunkFileItem + blockList, blockListErr := r.readBlockList(runID, artifact.ID) + chunks, err = listOrderedChunksForArtifact(r.fs, runID, artifact.ID, blockList) + if err != nil { + log.Error("Error list chunks: %v", errors.Join(blockListErr, err)) + ctx.HTTPError(http.StatusInternalServerError, "Error list chunks") + return + } + artifact.FileSize = chunks[len(chunks)-1].End + 1 + artifact.FileCompressedSize = chunks[len(chunks)-1].End + 1 + + if req.Size != artifact.FileSize { + log.Error("Error merge chunks size mismatch") + ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks size mismatch") + return + } + + 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{ diff --git a/tests/integration/api_actions_artifact_v4_test.go b/tests/integration/api_actions_artifact_v4_test.go index 1f17cb89d2a8c..8b3feebfed5e7 100644 --- a/tests/integration/api_actions_artifact_v4_test.go +++ b/tests/integration/api_actions_artifact_v4_test.go @@ -16,6 +16,7 @@ import ( "testing" "time" + actions_model "code.gitea.io/gitea/models/actions" auth_model "code.gitea.io/gitea/models/auth" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" @@ -51,11 +52,12 @@ func TestActionsArtifactV4UploadSingleFile(t *testing.T) { assert.NoError(t, err) table := []struct { - name string - version int32 - blockID bool - noLength bool - append int + name string + version int32 + contentType string + blockID bool + noLength bool + append int }{ { name: "artifact", @@ -101,6 +103,11 @@ func TestActionsArtifactV4UploadSingleFile(t *testing.T) { append: 4, blockID: true, }, + { + name: "artifact9.json", + version: 7, + contentType: "application/json", + }, } for _, entry := range table { @@ -123,9 +130,8 @@ func TestActionsArtifactV4UploadSingleFile(t *testing.T) { blocks := make([]string, 0, util.Iif(entry.blockID, entry.append+1, 0)) // get upload url - idx := strings.Index(uploadResp.SignedUploadUrl, "/twirp/") for i := range entry.append + 1 { - url := uploadResp.SignedUploadUrl[idx:] + url := uploadResp.SignedUploadUrl // See https://learn.microsoft.com/en-us/rest/api/storageservices/append-block // See https://learn.microsoft.com/en-us/rest/api/storageservices/put-block if entry.blockID { @@ -149,7 +155,7 @@ func TestActionsArtifactV4UploadSingleFile(t *testing.T) { if entry.blockID && entry.append > 0 { // https://learn.microsoft.com/en-us/rest/api/storageservices/put-block-list - blockListURL := uploadResp.SignedUploadUrl[idx:] + "&comp=blocklist" + blockListURL := uploadResp.SignedUploadUrl + "&comp=blocklist" // upload artifact blockList blockList := &actions.BlockList{ Latest: blocks, @@ -177,6 +183,13 @@ func TestActionsArtifactV4UploadSingleFile(t *testing.T) { var finalizeResp actions.FinalizeArtifactResponse protojson.Unmarshal(resp.Body.Bytes(), &finalizeResp) assert.True(t, finalizeResp.Ok) + + artifact := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionArtifact{ID: finalizeResp.ArtifactId}) + if entry.contentType != "" { + assert.Equal(t, entry.contentType, artifact.ContentEncoding) + } else { + assert.Equal(t, actions.ArtifactV4ContentEncoding, artifact.ContentEncoding) + } }) } } @@ -201,8 +214,7 @@ func TestActionsArtifactV4UploadSingleFileWrongChecksum(t *testing.T) { assert.Contains(t, uploadResp.SignedUploadUrl, "/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact") // get upload url - idx := strings.Index(uploadResp.SignedUploadUrl, "/twirp/") - url := uploadResp.SignedUploadUrl[idx:] + "&comp=block" + url := uploadResp.SignedUploadUrl + "&comp=block" // upload artifact chunk body := strings.Repeat("B", 1024) @@ -246,8 +258,7 @@ func TestActionsArtifactV4UploadSingleFileWithRetentionDays(t *testing.T) { assert.Contains(t, uploadResp.SignedUploadUrl, "/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact") // get upload url - idx := strings.Index(uploadResp.SignedUploadUrl, "/twirp/") - url := uploadResp.SignedUploadUrl[idx:] + "&comp=block" + url := uploadResp.SignedUploadUrl + "&comp=block" // upload artifact chunk body := strings.Repeat("A", 1024) @@ -293,9 +304,8 @@ func TestActionsArtifactV4UploadSingleFileWithPotentialHarmfulBlockID(t *testing assert.Contains(t, uploadResp.SignedUploadUrl, "/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact") // get upload urls - idx := strings.Index(uploadResp.SignedUploadUrl, "/twirp/") - url := uploadResp.SignedUploadUrl[idx:] + "&comp=block&blockid=%2f..%2fmyfile" - blockListURL := uploadResp.SignedUploadUrl[idx:] + "&comp=blocklist" + url := uploadResp.SignedUploadUrl + "&comp=block&blockid=%2f..%2fmyfile" + blockListURL := uploadResp.SignedUploadUrl + "&comp=blocklist" // upload artifact chunk body := strings.Repeat("A", 1024) @@ -342,63 +352,120 @@ func TestActionsArtifactV4UploadSingleFileWithChunksOutOfOrder(t *testing.T) { token, err := actions_service.CreateAuthorizationToken(48, 792, 193) assert.NoError(t, err) - // acquire artifact upload url - req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", toProtoJSON(&actions.CreateArtifactRequest{ - Version: 4, - Name: "artifactWithChunksOutOfOrder", - WorkflowRunBackendId: "792", - WorkflowJobRunBackendId: "193", - })).AddTokenAuth(token) - resp := MakeRequest(t, req, http.StatusOK) - var uploadResp actions.CreateArtifactResponse - protojson.Unmarshal(resp.Body.Bytes(), &uploadResp) - assert.True(t, uploadResp.Ok) - assert.Contains(t, uploadResp.SignedUploadUrl, "/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact") + table := []struct { + name string + artifactName string + serveDirect bool + contentType string + }{ + {name: "Upload-Zip", artifactName: "artifact-v4-upload", contentType: ""}, + {name: "Upload-Pdf", artifactName: "report-upload.pdf", contentType: "application/pdf"}, + {name: "Upload-Html", artifactName: "report-upload.html", contentType: "application/html"}, + {name: "ServeDirect-Zip", artifactName: "artifact-v4-upload-serve-direct", contentType: "", serveDirect: true}, + {name: "ServeDirect-Pdf", artifactName: "report-upload-serve-direct.pdf", contentType: "application/pdf", serveDirect: true}, + {name: "ServeDirect-Html", artifactName: "report-upload-serve-direct.html", contentType: "application/html", serveDirect: true}, + } - // get upload urls - idx := strings.Index(uploadResp.SignedUploadUrl, "/twirp/") - block1URL := uploadResp.SignedUploadUrl[idx:] + "&comp=block&blockid=block1" - block2URL := uploadResp.SignedUploadUrl[idx:] + "&comp=block&blockid=block2" - blockListURL := uploadResp.SignedUploadUrl[idx:] + "&comp=blocklist" - - // upload artifact chunks - bodyb := strings.Repeat("B", 1024) - req = NewRequestWithBody(t, "PUT", block2URL, strings.NewReader(bodyb)) - MakeRequest(t, req, http.StatusCreated) + for _, entry := range table { + t.Run(entry.name, func(t *testing.T) { + // Only AzureBlobStorageType supports ServeDirect Uploads + switch setting.Actions.ArtifactStorage.Type { + case setting.AzureBlobStorageType: + defer test.MockVariableValue(&setting.Actions.ArtifactStorage.AzureBlobConfig.ServeDirect, entry.serveDirect)() + default: + if entry.serveDirect { + t.Skip() + } + } + // acquire artifact upload url + req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", toProtoJSON(&actions.CreateArtifactRequest{ + Version: util.Iif[int32](entry.contentType != "", 7, 4), + Name: entry.artifactName, + WorkflowRunBackendId: "792", + WorkflowJobRunBackendId: "193", + MimeType: util.Iif(entry.contentType != "", wrapperspb.String(entry.contentType), nil), + })).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var uploadResp actions.CreateArtifactResponse + protojson.Unmarshal(resp.Body.Bytes(), &uploadResp) + assert.True(t, uploadResp.Ok) + if !entry.serveDirect { + assert.Contains(t, uploadResp.SignedUploadUrl, "/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact") + } - bodya := strings.Repeat("A", 1024) - req = NewRequestWithBody(t, "PUT", block1URL, strings.NewReader(bodya)) - MakeRequest(t, req, http.StatusCreated) + // get upload urls + block1URL := uploadResp.SignedUploadUrl + "&comp=block&blockid=" + base64.RawURLEncoding.EncodeToString([]byte("block1")) + block2URL := uploadResp.SignedUploadUrl + "&comp=block&blockid=" + base64.RawURLEncoding.EncodeToString([]byte("block2")) + blockListURL := uploadResp.SignedUploadUrl + "&comp=blocklist" + + // upload artifact chunks + bodyb := strings.Repeat("B", 1024) + req = NewRequestWithBody(t, "PUT", block2URL, strings.NewReader(bodyb)) + if entry.serveDirect { + req.Request.RequestURI = "" + nresp, err := http.DefaultClient.Do(req.Request) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, nresp.StatusCode) + } else { + MakeRequest(t, req, http.StatusCreated) + } - // upload artifact blockList - blockList := &actions.BlockList{ - Latest: []string{ - "block1", - "block2", - }, - } - rawBlockList, err := xml.Marshal(blockList) - assert.NoError(t, err) - req = NewRequestWithBody(t, "PUT", blockListURL, bytes.NewReader(rawBlockList)) - MakeRequest(t, req, http.StatusCreated) + bodya := strings.Repeat("A", 1024) + req = NewRequestWithBody(t, "PUT", block1URL, strings.NewReader(bodya)) + if entry.serveDirect { + req.Request.RequestURI = "" + nresp, err := http.DefaultClient.Do(req.Request) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, nresp.StatusCode) + } else { + MakeRequest(t, req, http.StatusCreated) + } - t.Logf("Create artifact confirm") + // upload artifact blockList + blockList := &actions.BlockList{ + Latest: []string{ + base64.RawURLEncoding.EncodeToString([]byte("block1")), + base64.RawURLEncoding.EncodeToString([]byte("block2")), + }, + } + rawBlockList, err := xml.Marshal(blockList) + assert.NoError(t, err) + req = NewRequestWithBody(t, "PUT", blockListURL, bytes.NewReader(rawBlockList)) + if entry.serveDirect { + req.Request.RequestURI = "" + nresp, err := http.DefaultClient.Do(req.Request) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, nresp.StatusCode) + } else { + MakeRequest(t, req, http.StatusCreated) + } - sha := sha256.Sum256([]byte(bodya + bodyb)) + t.Logf("Create artifact confirm") - // confirm artifact upload - req = NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/FinalizeArtifact", toProtoJSON(&actions.FinalizeArtifactRequest{ - Name: "artifactWithChunksOutOfOrder", - Size: 2048, - Hash: wrapperspb.String("sha256:" + hex.EncodeToString(sha[:])), - WorkflowRunBackendId: "792", - WorkflowJobRunBackendId: "193", - })). - AddTokenAuth(token) - resp = MakeRequest(t, req, http.StatusOK) - var finalizeResp actions.FinalizeArtifactResponse - protojson.Unmarshal(resp.Body.Bytes(), &finalizeResp) - assert.True(t, finalizeResp.Ok) + sha := sha256.Sum256([]byte(bodya + bodyb)) + + // confirm artifact upload + req = NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/FinalizeArtifact", toProtoJSON(&actions.FinalizeArtifactRequest{ + Name: entry.artifactName, + Size: 2048, + Hash: wrapperspb.String("sha256:" + hex.EncodeToString(sha[:])), + WorkflowRunBackendId: "792", + WorkflowJobRunBackendId: "193", + })). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var finalizeResp actions.FinalizeArtifactResponse + protojson.Unmarshal(resp.Body.Bytes(), &finalizeResp) + assert.True(t, finalizeResp.Ok) + + artifact := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionArtifact{ID: finalizeResp.ArtifactId}) + if entry.contentType != "" { + assert.Equal(t, entry.contentType, artifact.ContentEncoding) + } else { + assert.Equal(t, actions.ArtifactV4ContentEncoding, artifact.ContentEncoding) + } + }) + } } func TestActionsArtifactV4DownloadSingle(t *testing.T) { @@ -424,9 +491,8 @@ func TestActionsArtifactV4DownloadSingle(t *testing.T) { for _, entry := range table { t.Run(entry.Name, func(t *testing.T) { switch setting.Actions.ArtifactStorage.Type { - // FIXME ServeDirect Content-Type and Content-Disposition are partially broken in minio and not implemented in azure - // case setting.AzureBlobStorageType: - // defer test.MockVariableValue(&setting.Actions.ArtifactStorage.AzureBlobConfig.ServeDirect, entry.ServeDirect)() + case setting.AzureBlobStorageType: + defer test.MockVariableValue(&setting.Actions.ArtifactStorage.AzureBlobConfig.ServeDirect, entry.ServeDirect)() case setting.MinioStorageType: defer test.MockVariableValue(&setting.Actions.ArtifactStorage.MinioConfig.ServeDirect, entry.ServeDirect)() default: From 51280b042624a5bbc470a6863ef6e10bfe8f42b3 Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sun, 22 Mar 2026 11:41:00 +0100 Subject: [PATCH 44/78] fixes --- routers/api/actions/artifactsv4.go | 3 +-- .../api_actions_artifact_v4_test.go | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index 7c11c9b795e32..6a8e9995c2e5b 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -327,7 +327,7 @@ func (r *artifactV4Routes) createArtifact(ctx *ArtifactContext) { } encoding := req.GetMimeType().GetValue() fileName := artifactName - if !strings.Contains(encoding, "/") { + if !strings.Contains(encoding, "/") || strings.EqualFold(encoding, ArtifactV4ContentEncoding) && !strings.HasSuffix(fileName, ".zip") { encoding = ArtifactV4ContentEncoding fileName = artifactName + ".zip" } @@ -500,7 +500,6 @@ func (r *artifactV4Routes) finalizeArtifact(ctx *ArtifactContext) { } _ = n } else { - var chunks []*chunkFileItem blockList, blockListErr := r.readBlockList(runID, artifact.ID) chunks, err = listOrderedChunksForArtifact(r.fs, runID, artifact.ID, blockList) diff --git a/tests/integration/api_actions_artifact_v4_test.go b/tests/integration/api_actions_artifact_v4_test.go index 8b3feebfed5e7..287304b5bb39a 100644 --- a/tests/integration/api_actions_artifact_v4_test.go +++ b/tests/integration/api_actions_artifact_v4_test.go @@ -58,10 +58,12 @@ func TestActionsArtifactV4UploadSingleFile(t *testing.T) { blockID bool noLength bool append int + path string }{ { name: "artifact", version: 4, + path: "artifact.zip", }, { name: "artifact2", @@ -108,6 +110,18 @@ func TestActionsArtifactV4UploadSingleFile(t *testing.T) { version: 7, contentType: "application/json", }, + { + name: "artifact10", + version: 7, + contentType: "application/zip", + path: "artifact10.zip", + }, + { + name: "artifact11.zip", + version: 7, + contentType: "application/zip", + path: "artifact11.zip", + }, } for _, entry := range table { @@ -118,6 +132,7 @@ func TestActionsArtifactV4UploadSingleFile(t *testing.T) { Name: entry.name, WorkflowRunBackendId: "792", WorkflowJobRunBackendId: "193", + MimeType: util.Iif(entry.contentType != "", wrapperspb.String(entry.contentType), nil), })).AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) var uploadResp actions.CreateArtifactResponse @@ -190,6 +205,9 @@ func TestActionsArtifactV4UploadSingleFile(t *testing.T) { } else { assert.Equal(t, actions.ArtifactV4ContentEncoding, artifact.ContentEncoding) } + if entry.path != "" { + assert.Equal(t, entry.path, artifact.ArtifactPath) + } }) } } @@ -404,6 +422,7 @@ func TestActionsArtifactV4UploadSingleFileWithChunksOutOfOrder(t *testing.T) { if entry.serveDirect { req.Request.RequestURI = "" nresp, err := http.DefaultClient.Do(req.Request) + nresp.Body.Close() require.NoError(t, err) require.Equal(t, http.StatusCreated, nresp.StatusCode) } else { @@ -415,6 +434,7 @@ func TestActionsArtifactV4UploadSingleFileWithChunksOutOfOrder(t *testing.T) { if entry.serveDirect { req.Request.RequestURI = "" nresp, err := http.DefaultClient.Do(req.Request) + nresp.Body.Close() require.NoError(t, err) require.Equal(t, http.StatusCreated, nresp.StatusCode) } else { @@ -434,6 +454,7 @@ func TestActionsArtifactV4UploadSingleFileWithChunksOutOfOrder(t *testing.T) { if entry.serveDirect { req.Request.RequestURI = "" nresp, err := http.DefaultClient.Do(req.Request) + nresp.Body.Close() require.NoError(t, err) require.Equal(t, http.StatusCreated, nresp.StatusCode) } else { From 60b39df7ded3b1dc593b6f115715f3587524a714 Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sun, 22 Mar 2026 13:23:05 +0100 Subject: [PATCH 45/78] refactor content disposition formatting * use proper encoding in filename * encode the filename* field correctly if needed --- modules/actions/artifacts.go | 33 +++++------- modules/httplib/serve.go | 13 +++-- modules/public/mime_types.go | 50 +++++++++++++++++++ modules/public/mime_types_test.go | 32 ++++++++++++ modules/storage/minio.go | 6 +-- modules/storage/storage.go | 2 +- modules/storage/storage_test.go | 12 ++--- routers/api/actions/artifactsv4.go | 4 +- routers/api/v1/repo/action.go | 4 +- routers/common/actions.go | 3 +- routers/web/admin/diagnosis.go | 3 +- routers/web/repo/actions/view.go | 5 +- services/lfs/server.go | 3 +- .../api_actions_artifact_v4_test.go | 32 +++++++----- 14 files changed, 141 insertions(+), 61 deletions(-) create mode 100644 modules/public/mime_types_test.go diff --git a/modules/actions/artifacts.go b/modules/actions/artifacts.go index 60c229014560f..d7e21f9100c56 100644 --- a/modules/actions/artifacts.go +++ b/modules/actions/artifacts.go @@ -10,6 +10,7 @@ import ( "strings" actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/services/context" @@ -21,19 +22,9 @@ func IsArtifactV4(art *actions_model.ActionArtifact) bool { return strings.Contains(art.ContentEncoding, "/") } -type ContentDispositionType string - -const ( - ContentDispositionInline ContentDispositionType = "inline" - ContentDispositionAttachment ContentDispositionType = "attachment" -) - -func GetArtifactContentTypeAndDisposition(artifact *actions_model.ActionArtifact, cdt ContentDispositionType) (contentType, contentDisposition string, _ error) { - // FIXME check if contentType is safe or application/html? +func GetArtifactContentTypeAndDisposition(artifact *actions_model.ActionArtifact) (contentType, contentDisposition string, _ error) { contentType = mime.FormatMediaType(artifact.ContentEncoding, nil) - contentDisposition = mime.FormatMediaType(string(cdt), map[string]string{ - "filename": artifact.ArtifactPath, - }) + contentDisposition = public.EncodeContentDisposition(public.ContentDispositionInline, artifact.ArtifactPath) if contentType == "" || contentDisposition == "" { setting.PanicInDevOrTesting("cannot generate mime headers") return "", "", errors.New("cannot generate mime headers") @@ -41,8 +32,8 @@ func GetArtifactContentTypeAndDisposition(artifact *actions_model.ActionArtifact return contentType, contentDisposition, nil } -func GetArtifactV4ServeDirectURL(ctx *context.Base, art *actions_model.ActionArtifact, method string, cdt ContentDispositionType) (string, error) { - contentType, contentDisposition, err := GetArtifactContentTypeAndDisposition(art, cdt) +func GetArtifactV4ServeDirectURL(ctx *context.Base, art *actions_model.ActionArtifact, method string) (string, error) { + contentType, contentDisposition, err := GetArtifactContentTypeAndDisposition(art) if err != nil { return "", err } @@ -56,9 +47,9 @@ func GetArtifactV4ServeDirectURL(ctx *context.Base, art *actions_model.ActionArt return "", nil } -func DownloadArtifactV4ServeDirectOnly(ctx *context.Base, art *actions_model.ActionArtifact, cdt ContentDispositionType) (bool, error) { +func DownloadArtifactV4ServeDirectOnly(ctx *context.Base, art *actions_model.ActionArtifact) (bool, error) { if setting.Actions.ArtifactStorage.ServeDirect() { - u, err := GetArtifactV4ServeDirectURL(ctx, art, ctx.Req.Method, cdt) + u, err := GetArtifactV4ServeDirectURL(ctx, art, ctx.Req.Method) if u != "" && err == nil { ctx.Redirect(u, http.StatusFound) return true, nil @@ -67,14 +58,14 @@ func DownloadArtifactV4ServeDirectOnly(ctx *context.Base, art *actions_model.Act return false, nil } -func DownloadArtifactV4Fallback(ctx *context.Base, art *actions_model.ActionArtifact, cdt ContentDispositionType) error { +func DownloadArtifactV4Fallback(ctx *context.Base, art *actions_model.ActionArtifact) error { f, err := storage.ActionsArtifacts.Open(art.StoragePath) if err != nil { return err } defer f.Close() - contentType, contentDisposition, err := GetArtifactContentTypeAndDisposition(art, cdt) + contentType, contentDisposition, err := GetArtifactContentTypeAndDisposition(art) if err != nil { return err } @@ -89,10 +80,10 @@ func DownloadArtifactV4Fallback(ctx *context.Base, art *actions_model.ActionArti return nil } -func DownloadArtifactV4(ctx *context.Base, art *actions_model.ActionArtifact, cdt ContentDispositionType) error { - ok, err := DownloadArtifactV4ServeDirectOnly(ctx, art, cdt) +func DownloadArtifactV4(ctx *context.Base, art *actions_model.ActionArtifact) error { + ok, err := DownloadArtifactV4ServeDirectOnly(ctx, art) if ok || err != nil { return err } - return DownloadArtifactV4Fallback(ctx, art, cdt) + return DownloadArtifactV4Fallback(ctx, art) } diff --git a/modules/httplib/serve.go b/modules/httplib/serve.go index fc7edc36c438c..ba7058053bda5 100644 --- a/modules/httplib/serve.go +++ b/modules/httplib/serve.go @@ -9,7 +9,6 @@ import ( "fmt" "io" "net/http" - "net/url" "path" "path/filepath" "strconv" @@ -19,6 +18,7 @@ import ( charsetModule "code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/httpcache" + "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/typesniffer" "code.gitea.io/gitea/modules/util" @@ -30,7 +30,7 @@ type ServeHeaderOptions struct { ContentType string // defaults to "application/octet-stream" ContentTypeCharset string ContentLength *int64 - Disposition string // defaults to "attachment" + Disposition public.ContentDispositionType // defaults to "attachment" Filename string CacheIsPublic bool CacheDuration time.Duration // defaults to 5 minutes @@ -64,11 +64,10 @@ func ServeSetHeaders(w http.ResponseWriter, opts *ServeHeaderOptions) { if opts.Filename != "" { disposition := opts.Disposition if disposition == "" { - disposition = "attachment" + disposition = public.ContentDispositionAttachment } - backslashEscapedName := strings.ReplaceAll(strings.ReplaceAll(opts.Filename, `\`, `\\`), `"`, `\"`) // \ -> \\, " -> \" - header.Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"; filename*=UTF-8''%s`, disposition, backslashEscapedName, url.PathEscape(opts.Filename))) + header.Set("Content-Disposition", public.EncodeContentDisposition(disposition, opts.Filename)) header.Set("Access-Control-Expose-Headers", "Content-Disposition") } @@ -124,9 +123,9 @@ func setServeHeadersByFile(r *http.Request, w http.ResponseWriter, mineBuf []byt } // TODO: UNIFY-CONTENT-DISPOSITION-FROM-STORAGE - opts.Disposition = "inline" + opts.Disposition = public.ContentDispositionInline if sniffedType.IsSvgImage() && !setting.UI.SVG.Enabled { - opts.Disposition = "attachment" + opts.Disposition = public.ContentDispositionAttachment } ServeSetHeaders(w, opts) diff --git a/modules/public/mime_types.go b/modules/public/mime_types.go index fef85d77cbefa..abf8840b3bc2f 100644 --- a/modules/public/mime_types.go +++ b/modules/public/mime_types.go @@ -4,7 +4,11 @@ package public import ( + "fmt" + "mime" "strings" + + "code.gitea.io/gitea/modules/setting" ) // wellKnownMimeTypesLower comes from Golang's builtin mime package: `builtinTypesLower`, see the comment of DetectWellKnownMimeType @@ -40,3 +44,49 @@ func DetectWellKnownMimeType(ext string) string { ext = strings.ToLower(ext) return wellKnownMimeTypesLower[ext] } + +type ContentDispositionType string + +const ( + ContentDispositionInline ContentDispositionType = "inline" + ContentDispositionAttachment ContentDispositionType = "attachment" +) + +func needsEncodingRune(b rune) bool { + return (b < ' ' || b > '~') && b != '\t' +} + +// getSafeName replaces all invalid chars in the filename field by underscore +func getSafeName(s string) (_ string, needsEncoding bool) { + var out strings.Builder + for _, b := range s { + if needsEncodingRune(b) { + needsEncoding = true + out.WriteRune('_') + } else { + out.WriteRune(b) + } + } + return out.String(), needsEncoding +} + +// EncodeContentDisposition encodes a correct Content-Disposition Header +func EncodeContentDisposition(t ContentDispositionType, filename string) string { + safeFilename, needsEncoding := getSafeName(filename) + result := mime.FormatMediaType(string(t), map[string]string{"filename": safeFilename}) + // No need for the utf8 encoding + if !needsEncoding { + return result + } + utf8Result := mime.FormatMediaType(string(t), map[string]string{"filename": filename}) + + // The mime package might has unexpected results in other go versions + // Make tests instance fail, otherwise use the default behavior of the go mime package + if !strings.HasPrefix(result, fmt.Sprintf("%s; filename=", string(t))) || !strings.HasPrefix(utf8Result, fmt.Sprintf("%s; filename*=", string(t))) { + setting.PanicInDevOrTesting("Unexpected mime package result %s", result) + return utf8Result + } + + encodedFileName := strings.TrimPrefix(utf8Result, string(t)) + return result + encodedFileName +} diff --git a/modules/public/mime_types_test.go b/modules/public/mime_types_test.go new file mode 100644 index 0000000000000..dd163451cb0e3 --- /dev/null +++ b/modules/public/mime_types_test.go @@ -0,0 +1,32 @@ +package public + +import ( + "mime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestContentDisposition(t *testing.T) { + table := []struct { + disposition ContentDispositionType + filename string + header string + }{ + {disposition: ContentDispositionInline, filename: "test.txt", header: "inline; filename=test.txt"}, + {disposition: ContentDispositionInline, filename: "test❌.txt", header: "inline; filename=test_.txt; filename*=utf-8''test%E2%9D%8C.txt"}, + {disposition: ContentDispositionInline, filename: "test ❌.txt", header: "inline; filename=\"test _.txt\"; filename*=utf-8''test%20%E2%9D%8C.txt"}, + } + + for _, entry := range table { + t.Run(string(entry.disposition)+"_"+entry.filename, func(t *testing.T) { + encoded := EncodeContentDisposition(entry.disposition, entry.filename) + assert.Equal(t, entry.header, encoded) + disposition, params, err := mime.ParseMediaType(encoded) + require.NoError(t, err) + assert.Equal(t, string(entry.disposition), disposition) + assert.Equal(t, entry.filename, params["filename"]) + }) + } +} diff --git a/modules/storage/minio.go b/modules/storage/minio.go index 1355280f36743..ace78bb610596 100644 --- a/modules/storage/minio.go +++ b/modules/storage/minio.go @@ -23,11 +23,7 @@ import ( "github.com/minio/minio-go/v7/pkg/credentials" ) -var ( - _ ObjectStorage = &MinioStorage{} - - quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"") -) +var _ ObjectStorage = &MinioStorage{} type minioObject struct { *minio.Object diff --git a/modules/storage/storage.go b/modules/storage/storage.go index 2491c77a3e0a3..ed995c607651d 100644 --- a/modules/storage/storage.go +++ b/modules/storage/storage.go @@ -85,7 +85,7 @@ func prepareServeDirectOptions(optsOptional *ServeDirectOptions, name string) (r // When using ServeDirect, the URL is from the object storage's web server, // it is not the same origin as Gitea server, so it should be safe enough to use "inline" to render the content directly. // If a browser doesn't support the content type to be displayed inline, browser will download with the filename. - ret.ContentDisposition = fmt.Sprintf(`inline; filename="%s"`, quoteEscaper.Replace(name)) + ret.ContentDisposition = public.EncodeContentDisposition(public.ContentDispositionInline, name) } return ret } diff --git a/modules/storage/storage_test.go b/modules/storage/storage_test.go index 4156723c36480..2332b7df2eaf1 100644 --- a/modules/storage/storage_test.go +++ b/modules/storage/storage_test.go @@ -78,28 +78,28 @@ func testBlobStorageURLContentTypeAndDisposition(t *testing.T, typStr Type, cfg testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.txt", ServeDirectOptions{ ContentType: "text/plain; charset=utf-8", - ContentDisposition: `inline; filename="test.txt"`, + ContentDisposition: `inline; filename=test.txt`, }, nil) testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.pdf", ServeDirectOptions{ ContentType: "application/pdf", - ContentDisposition: `inline; filename="test.pdf"`, + ContentDisposition: `inline; filename=test.pdf`, }, nil) testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.wasm", ServeDirectOptions{ - ContentDisposition: `inline; filename="test.wasm"`, + ContentDisposition: `inline; filename=test.wasm`, }, nil) testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.wasm", ServeDirectOptions{ - ContentDisposition: `inline; filename="test.wasm"`, + ContentDisposition: `inline; filename=test.wasm`, }, &ServeDirectOptions{}) testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.txt", ServeDirectOptions{ ContentType: "application/octet-stream", - ContentDisposition: `inline; filename="test.xml"`, + ContentDisposition: `inline; filename=test.xml`, }, &ServeDirectOptions{ ContentType: "application/octet-stream", - ContentDisposition: `inline; filename="test.xml"`, + ContentDisposition: `inline; filename=test.xml`, }) assert.NoError(t, s.Delete(testfilename)) diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index 6a8e9995c2e5b..dc353c1ed2090 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -613,7 +613,7 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) { if setting.Actions.ArtifactStorage.ServeDirect() { // DO NOT USE the http POST method coming from the getSignedArtifactURL endpoint - u, err := actions.GetArtifactV4ServeDirectURL(ctx.Base, artifact, http.MethodGet, actions.ContentDispositionAttachment) + u, err := actions.GetArtifactV4ServeDirectURL(ctx.Base, artifact, http.MethodGet) if u != "" && err == nil { respData.SignedUrl = u } @@ -643,7 +643,7 @@ func (r *artifactV4Routes) downloadArtifact(ctx *ArtifactContext) { return } - err = actions.DownloadArtifactV4Fallback(ctx.Base, artifact, actions.ContentDispositionAttachment) + err = actions.DownloadArtifactV4Fallback(ctx.Base, artifact) if err != nil { log.Error("Error serve artifact: %v", err) ctx.HTTPError(http.StatusInternalServerError, err.Error()) diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 56b8d91f3ff62..6d9ce36249ae2 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -1831,7 +1831,7 @@ func DownloadArtifact(ctx *context.APIContext) { } if actions.IsArtifactV4(art) { - ok, err := actions.DownloadArtifactV4ServeDirectOnly(ctx.Base, art, actions.ContentDispositionAttachment) + ok, err := actions.DownloadArtifactV4ServeDirectOnly(ctx.Base, art) if ok { return } @@ -1887,7 +1887,7 @@ func DownloadArtifactRaw(ctx *context.APIContext) { return } if actions.IsArtifactV4(art) { - err := actions.DownloadArtifactV4(ctx.Base, art, "attachment") + err := actions.DownloadArtifactV4(ctx.Base, art) if err != nil { ctx.APIErrorInternal(err) return diff --git a/routers/common/actions.go b/routers/common/actions.go index 39d2111f5a1b1..a443340b526ea 100644 --- a/routers/common/actions.go +++ b/routers/common/actions.go @@ -10,6 +10,7 @@ import ( actions_model "code.gitea.io/gitea/models/actions" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/actions" + "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/context" ) @@ -62,7 +63,7 @@ func DownloadActionsRunJobLogs(ctx *context.Base, ctxRepo *repo_model.Repository ContentLength: &task.LogSize, ContentType: "text/plain", ContentTypeCharset: "utf-8", - Disposition: "attachment", + Disposition: public.ContentDispositionAttachment, }) return nil } diff --git a/routers/web/admin/diagnosis.go b/routers/web/admin/diagnosis.go index 5395529d66a5d..d6198bfd8447f 100644 --- a/routers/web/admin/diagnosis.go +++ b/routers/web/admin/diagnosis.go @@ -10,6 +10,7 @@ import ( "time" "code.gitea.io/gitea/modules/httplib" + "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/tailmsg" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/context" @@ -20,7 +21,7 @@ func MonitorDiagnosis(ctx *context.Context) { httplib.ServeSetHeaders(ctx.Resp, &httplib.ServeHeaderOptions{ ContentType: "application/zip", - Disposition: "attachment", + Disposition: public.ContentDispositionAttachment, Filename: fmt.Sprintf("gitea-diagnosis-%s.zip", time.Now().Format("20060102-150405")), }) diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 9b0f5db6562ea..2e9ab7636c7c6 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -25,6 +25,7 @@ import ( "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/translation" @@ -717,7 +718,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) { } if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) { - err := actions.DownloadArtifactV4(ctx.Base, artifacts[0], actions.ContentDispositionInline) + err := actions.DownloadArtifactV4(ctx.Base, artifacts[0]) if err != nil { ctx.ServerError("DownloadArtifactV4", err) return @@ -725,7 +726,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) { return } - ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip; filename*=UTF-8''%s.zip", url.PathEscape(artifactName), artifactName)) + ctx.Resp.Header().Set("Content-Disposition", public.EncodeContentDisposition(public.ContentDispositionAttachment, artifactName+".zip")) // Artifacts using the v1-v3 backend are stored as multiple individual files per artifact on the backend // Those need to be zipped for download diff --git a/services/lfs/server.go b/services/lfs/server.go index fc09eb58ca4bb..a51c89bbcb6f6 100644 --- a/services/lfs/server.go +++ b/services/lfs/server.go @@ -30,6 +30,7 @@ import ( "code.gitea.io/gitea/modules/json" lfs_module "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/services/context" @@ -172,7 +173,7 @@ func DownloadHandler(ctx *context.Context) { if len(filename) > 0 { decodedFilename, err := base64.RawURLEncoding.DecodeString(filename) if err == nil { - ctx.Resp.Header().Set("Content-Disposition", "attachment; filename=\""+string(decodedFilename)+"\"") + ctx.Resp.Header().Set("Content-Disposition", public.EncodeContentDisposition(public.ContentDispositionAttachment, string(decodedFilename))) ctx.Resp.Header().Set("Access-Control-Expose-Headers", "Content-Disposition") } } diff --git a/tests/integration/api_actions_artifact_v4_test.go b/tests/integration/api_actions_artifact_v4_test.go index 287304b5bb39a..e9c1edbb6d1dc 100644 --- a/tests/integration/api_actions_artifact_v4_test.go +++ b/tests/integration/api_actions_artifact_v4_test.go @@ -11,6 +11,7 @@ import ( "encoding/xml" "fmt" "io" + "mime" "net/http" "strings" "testing" @@ -496,17 +497,19 @@ func TestActionsArtifactV4DownloadSingle(t *testing.T) { assert.NoError(t, err) table := []struct { - Name string - ArtifactName string - ServeDirect bool - ContentType string + Name string + ArtifactName string + FileName string + ServeDirect bool + ContentType string + ContentDisposition string }{ - {Name: "Download-Zip", ArtifactName: "artifact-v4-download", ContentType: actions.ArtifactV4ContentEncoding}, - {Name: "Download-Pdf", ArtifactName: "report.pdf", ContentType: "application/pdf"}, - {Name: "Download-Html", ArtifactName: "report.html", ContentType: "application/html"}, - {Name: "ServeDirect-Zip", ArtifactName: "artifact-v4-download", ContentType: actions.ArtifactV4ContentEncoding, ServeDirect: true}, - {Name: "ServeDirect-Pdf", ArtifactName: "report.pdf", ContentType: "application/pdf", ServeDirect: true}, - {Name: "ServeDirect-Html", ArtifactName: "report.html", ContentType: "application/html", ServeDirect: true}, + {Name: "Download-Zip", ArtifactName: "artifact-v4-download", FileName: "artifact-v4-download.zip", ContentType: actions.ArtifactV4ContentEncoding}, + {Name: "Download-Pdf", ArtifactName: "report.pdf", FileName: "report.pdf", ContentType: "application/pdf"}, + {Name: "Download-Html", ArtifactName: "report.html", FileName: "report.html", ContentType: "application/html"}, + {Name: "ServeDirect-Zip", ArtifactName: "artifact-v4-download", FileName: "artifact-v4-download.zip", ContentType: actions.ArtifactV4ContentEncoding, ServeDirect: true}, + {Name: "ServeDirect-Pdf", ArtifactName: "report.pdf", FileName: "report.pdf", ContentType: "application/pdf", ServeDirect: true}, + {Name: "ServeDirect-Html", ArtifactName: "report.html", FileName: "report.html", ContentType: "application/html", ServeDirect: true}, } for _, entry := range table { @@ -556,6 +559,7 @@ func TestActionsArtifactV4DownloadSingle(t *testing.T) { assert.NotEmpty(t, finalizeResp.SignedUrl) body := strings.Repeat("D", 1024) + var contentDisposition string if entry.ServeDirect { externalReq, err := http.NewRequestWithContext(t.Context(), http.MethodGet, finalizeResp.SignedUrl, nil) require.NoError(t, err) @@ -563,7 +567,7 @@ func TestActionsArtifactV4DownloadSingle(t *testing.T) { require.NoError(t, err) assert.Equal(t, http.StatusOK, externalResp.StatusCode) assert.Equal(t, entry.ContentType, externalResp.Header.Get("Content-Type")) - // FIXME Content-Disposition Check + contentDisposition = externalResp.Header.Get("Content-Disposition") buf := make([]byte, 1024) n, err := io.ReadAtLeast(externalResp.Body, buf, len(buf)) externalResp.Body.Close() @@ -574,9 +578,13 @@ func TestActionsArtifactV4DownloadSingle(t *testing.T) { req = NewRequest(t, "GET", finalizeResp.SignedUrl) resp = MakeRequest(t, req, http.StatusOK) assert.Equal(t, entry.ContentType, resp.Header().Get("Content-Type")) - // FIXME Content-Type-Disposition Check + contentDisposition = resp.Header().Get("Content-Disposition") assert.Equal(t, body, resp.Body.String()) } + disposition, param, err := mime.ParseMediaType(contentDisposition) + require.NoError(t, err) + assert.Equal(t, "inline", disposition) + assert.Equal(t, entry.FileName, param["filename"]) }) } } From 6772530a5657ba547edbef4b212cc24143e7d31b Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sun, 22 Mar 2026 13:33:32 +0100 Subject: [PATCH 46/78] add copyright --- modules/public/mime_types_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/modules/public/mime_types_test.go b/modules/public/mime_types_test.go index dd163451cb0e3..72599b244e0c9 100644 --- a/modules/public/mime_types_test.go +++ b/modules/public/mime_types_test.go @@ -1,3 +1,6 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + package public import ( @@ -17,6 +20,7 @@ func TestContentDisposition(t *testing.T) { {disposition: ContentDispositionInline, filename: "test.txt", header: "inline; filename=test.txt"}, {disposition: ContentDispositionInline, filename: "test❌.txt", header: "inline; filename=test_.txt; filename*=utf-8''test%E2%9D%8C.txt"}, {disposition: ContentDispositionInline, filename: "test ❌.txt", header: "inline; filename=\"test _.txt\"; filename*=utf-8''test%20%E2%9D%8C.txt"}, + {disposition: ContentDispositionInline, filename: "\"test.txt", header: "inline; filename=\"\\\"test.txt\""}, } for _, entry := range table { From 1282340e4f91b96d5e586c1ff17273640efe9dbf Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sun, 22 Mar 2026 17:13:28 +0100 Subject: [PATCH 47/78] apply feedback --- modules/actions/artifacts.go | 22 ++++-- routers/api/actions/artifactsv4.go | 67 ++++++++++++++----- .../api_actions_artifact_v4_test.go | 6 ++ 3 files changed, 74 insertions(+), 21 deletions(-) diff --git a/modules/actions/artifacts.go b/modules/actions/artifacts.go index d7e21f9100c56..344cabe457856 100644 --- a/modules/actions/artifacts.go +++ b/modules/actions/artifacts.go @@ -10,6 +10,7 @@ import ( "strings" actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" @@ -41,10 +42,11 @@ func GetArtifactV4ServeDirectURL(ctx *context.Base, art *actions_model.ActionArt ContentType: contentType, ContentDisposition: contentDisposition, }) - if u != nil && err == nil { - return u.String(), nil + if err != nil { + log.Error("GetArtifactV4ServeDirectURL failed with error: %v", err) + return "", nil } - return "", nil + return u.String(), nil } func DownloadArtifactV4ServeDirectOnly(ctx *context.Base, art *actions_model.ActionArtifact) (bool, error) { @@ -70,10 +72,20 @@ func DownloadArtifactV4Fallback(ctx *context.Base, art *actions_model.ActionArti return err } + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + return err + } + ctx.Resp.Header().Set("Content-Type", contentType) ctx.Resp.Header().Set("Content-Disposition", contentDisposition) - // HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context, it seems fine to render it inline - if mediaType, _, err := mime.ParseMediaType(contentType); err != nil || mediaType != "application/pdf" { + + switch mediaType { + case "application/pdf": + // HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context, it seems fine to render it inline + ctx.Resp.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") + default: + // Disable script execution of html files, since we serve the file from the same domain as gitea ctx.Resp.Header().Set("Content-Security-Policy", "sandbox; style-src 'unsafe-inline'; default-src 'none';") } http.ServeContent(ctx.Resp, ctx.Req, art.ArtifactPath, art.CreatedUnix.AsLocalTime(), f) diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index dc353c1ed2090..d0bd56bd351c6 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -326,6 +326,10 @@ func (r *artifactV4Routes) createArtifact(ctx *ArtifactContext) { retentionDays = int64(time.Until(req.ExpiresAt.AsTime()).Hours() / 24) } encoding := req.GetMimeType().GetValue() + // Validate media type + if encoding != "" { + encoding, _, _ = mime.ParseMediaType(encoding) + } fileName := artifactName if !strings.Contains(encoding, "/") || strings.EqualFold(encoding, ArtifactV4ContentEncoding) && !strings.HasSuffix(fileName, ".zip") { encoding = ArtifactV4ContentEncoding @@ -345,13 +349,18 @@ func (r *artifactV4Routes) createArtifact(ctx *ArtifactContext) { var respData CreateArtifactResponse if setting.Actions.ArtifactStorage.ServeDirect() && setting.Actions.ArtifactStorage.Type == setting.AzureBlobStorageType { - storagePath := fmt.Sprintf("%d/%d/%d.%s", artifact.RunID%255, artifact.ID%255, time.Now().UnixNano(), ".blob") + storagePath := fmt.Sprintf("%d/%d/%d.blob", artifact.RunID%255, artifact.ID%255, time.Now().UnixNano()) if artifact.StoragePath != "" { _ = storage.ActionsArtifacts.Delete(artifact.StoragePath) } artifact.StoragePath = storagePath artifact.Status = actions_model.ArtifactStatusUploadPending - u, _ := storage.ActionsArtifacts.ServeDirectURL(artifact.StoragePath, artifact.ArtifactPath, http.MethodPut, nil) + u, err := storage.ActionsArtifacts.ServeDirectURL(artifact.StoragePath, artifact.ArtifactPath, http.MethodPut, nil) + if err != nil { + log.Error("Error ServeDirectURL: %v", err) + ctx.HTTPError(http.StatusInternalServerError, "Error ServeDirectURL") + return + } respData = CreateArtifactResponse{ Ok: true, SignedUploadUrl: u.String(), @@ -482,23 +491,49 @@ func (r *artifactV4Routes) finalizeArtifact(ctx *ArtifactContext) { } if setting.Actions.ArtifactStorage.ServeDirect() && setting.Actions.ArtifactStorage.Type == setting.AzureBlobStorageType { - hashSha256 := sha256.New() - obj, err := storage.ActionsArtifacts.Open(artifact.StoragePath) - if err != nil { - log.Error("Error read block: %v", err) - ctx.HTTPError(http.StatusInternalServerError, "Error read block") - return + checksumValue, found := strings.CutPrefix(checksum, "sha256:") + var actualLength int64 + if found { + hashSha256 := sha256.New() + obj, err := storage.ActionsArtifacts.Open(artifact.StoragePath) + if err != nil { + log.Error("Error read block: %v", err) + ctx.HTTPError(http.StatusInternalServerError, "Error read block") + return + } + defer obj.Close() + actualLength, err = io.Copy(hashSha256, obj) + if err != nil { + log.Error("Error read block: %v", err) + ctx.HTTPError(http.StatusInternalServerError, "Error read block") + return + } + rawChecksum := hashSha256.Sum(nil) + actualChecksum := hex.EncodeToString(rawChecksum) + if checksumValue != actualChecksum { + log.Error("Error merge chunks: checksum mismatch") + ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks: checksum mismatch") + return + } + } else { + fi, err := storage.ActionsArtifacts.Stat(artifact.StoragePath) + if err != nil { + log.Error("Error stat block: %v", err) + ctx.HTTPError(http.StatusInternalServerError, "Error stat block") + return + } + actualLength = fi.Size() } - defer obj.Close() - n, _ := io.Copy(hashSha256, obj) - rawChecksum := hashSha256.Sum(nil) - actualChecksum := hex.EncodeToString(rawChecksum) - if checksum != "sha256:"+actualChecksum { - log.Error("Error merge chunks: checksum mismatch") - ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks: checksum mismatch") + // Update artifact metadata and status now that the upload is confirmed. + artifact.FileSize = actualLength + artifact.FileCompressedSize = actualLength + artifact.Status = actions_model.ArtifactStatusUploadConfirmed + if err := actions_model.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil { + log.Error("Error UpdateArtifactByID: %v", err) + ctx.HTTPError(http.StatusInternalServerError, "Error UpdateArtifactByID") return } - _ = n + } else { var chunks []*chunkFileItem blockList, blockListErr := r.readBlockList(runID, artifact.ID) diff --git a/tests/integration/api_actions_artifact_v4_test.go b/tests/integration/api_actions_artifact_v4_test.go index e9c1edbb6d1dc..306908a1cc0a8 100644 --- a/tests/integration/api_actions_artifact_v4_test.go +++ b/tests/integration/api_actions_artifact_v4_test.go @@ -209,6 +209,9 @@ func TestActionsArtifactV4UploadSingleFile(t *testing.T) { if entry.path != "" { assert.Equal(t, entry.path, artifact.ArtifactPath) } + assert.Equal(t, actions_model.ArtifactStatusUploadConfirmed, artifact.Status) + assert.Equal(t, int64(entry.append+1)*1024, artifact.FileSize) + assert.Equal(t, int64(entry.append+1)*1024, artifact.FileCompressedSize) }) } } @@ -486,6 +489,9 @@ func TestActionsArtifactV4UploadSingleFileWithChunksOutOfOrder(t *testing.T) { } else { assert.Equal(t, actions.ArtifactV4ContentEncoding, artifact.ContentEncoding) } + assert.Equal(t, actions_model.ArtifactStatusUploadConfirmed, artifact.Status) + assert.Equal(t, int64(2048), artifact.FileSize) + assert.Equal(t, int64(2048), artifact.FileCompressedSize) }) } } From 95cfb1e343c1f218153c42fa0949a913a41802fd Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sun, 22 Mar 2026 17:28:15 +0100 Subject: [PATCH 48/78] fix --- routers/api/actions/artifactsv4.go | 1 - 1 file changed, 1 deletion(-) diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index d0bd56bd351c6..3fd05706c9aff 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -533,7 +533,6 @@ func (r *artifactV4Routes) finalizeArtifact(ctx *ArtifactContext) { ctx.HTTPError(http.StatusInternalServerError, "Error UpdateArtifactByID") return } - } else { var chunks []*chunkFileItem blockList, blockListErr := r.readBlockList(runID, artifact.ID) From fc05027cf40f74375a867421270c85b66f7323ad Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sun, 22 Mar 2026 18:03:34 +0100 Subject: [PATCH 49/78] feedback --- modules/public/mime_types.go | 2 +- routers/api/actions/artifactsv4.go | 7 +++++++ tests/integration/api_actions_artifact_v4_test.go | 6 +++--- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/modules/public/mime_types.go b/modules/public/mime_types.go index abf8840b3bc2f..ca3b314c2560d 100644 --- a/modules/public/mime_types.go +++ b/modules/public/mime_types.go @@ -80,7 +80,7 @@ func EncodeContentDisposition(t ContentDispositionType, filename string) string } utf8Result := mime.FormatMediaType(string(t), map[string]string{"filename": filename}) - // The mime package might has unexpected results in other go versions + // The mime package might have unexpected results in other go versions // Make tests instance fail, otherwise use the default behavior of the go mime package if !strings.HasPrefix(result, fmt.Sprintf("%s; filename=", string(t))) || !strings.HasPrefix(utf8Result, fmt.Sprintf("%s; filename*=", string(t))) { setting.PanicInDevOrTesting("Unexpected mime package result %s", result) diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index 3fd05706c9aff..dfa8658b1c4fe 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -524,6 +524,13 @@ func (r *artifactV4Routes) finalizeArtifact(ctx *ArtifactContext) { } actualLength = fi.Size() } + + if req.Size != actualLength { + log.Error("Error merge chunks: length mismatch") + ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks: length mismatch") + return + } + // Update artifact metadata and status now that the upload is confirmed. artifact.FileSize = actualLength artifact.FileCompressedSize = actualLength diff --git a/tests/integration/api_actions_artifact_v4_test.go b/tests/integration/api_actions_artifact_v4_test.go index 306908a1cc0a8..2654e7fb28953 100644 --- a/tests/integration/api_actions_artifact_v4_test.go +++ b/tests/integration/api_actions_artifact_v4_test.go @@ -426,8 +426,8 @@ func TestActionsArtifactV4UploadSingleFileWithChunksOutOfOrder(t *testing.T) { if entry.serveDirect { req.Request.RequestURI = "" nresp, err := http.DefaultClient.Do(req.Request) - nresp.Body.Close() require.NoError(t, err) + nresp.Body.Close() require.Equal(t, http.StatusCreated, nresp.StatusCode) } else { MakeRequest(t, req, http.StatusCreated) @@ -438,8 +438,8 @@ func TestActionsArtifactV4UploadSingleFileWithChunksOutOfOrder(t *testing.T) { if entry.serveDirect { req.Request.RequestURI = "" nresp, err := http.DefaultClient.Do(req.Request) - nresp.Body.Close() require.NoError(t, err) + nresp.Body.Close() require.Equal(t, http.StatusCreated, nresp.StatusCode) } else { MakeRequest(t, req, http.StatusCreated) @@ -458,8 +458,8 @@ func TestActionsArtifactV4UploadSingleFileWithChunksOutOfOrder(t *testing.T) { if entry.serveDirect { req.Request.RequestURI = "" nresp, err := http.DefaultClient.Do(req.Request) - nresp.Body.Close() require.NoError(t, err) + nresp.Body.Close() require.Equal(t, http.StatusCreated, nresp.StatusCode) } else { MakeRequest(t, req, http.StatusCreated) From e1fb863d0fa3b9d0fc2cca95b4e34489fc2ab6ea Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Sun, 22 Mar 2026 23:46:36 +0100 Subject: [PATCH 50/78] add more tests for ContentDisposition --- modules/public/mime_types_test.go | 32 +++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/modules/public/mime_types_test.go b/modules/public/mime_types_test.go index 72599b244e0c9..693d4e866fbcf 100644 --- a/modules/public/mime_types_test.go +++ b/modules/public/mime_types_test.go @@ -5,6 +5,7 @@ package public import ( "mime" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -12,15 +13,42 @@ import ( ) func TestContentDisposition(t *testing.T) { - table := []struct { + type testEntry struct { disposition ContentDispositionType filename string header string - }{ + } + table := []testEntry{ {disposition: ContentDispositionInline, filename: "test.txt", header: "inline; filename=test.txt"}, {disposition: ContentDispositionInline, filename: "test❌.txt", header: "inline; filename=test_.txt; filename*=utf-8''test%E2%9D%8C.txt"}, {disposition: ContentDispositionInline, filename: "test ❌.txt", header: "inline; filename=\"test _.txt\"; filename*=utf-8''test%20%E2%9D%8C.txt"}, {disposition: ContentDispositionInline, filename: "\"test.txt", header: "inline; filename=\"\\\"test.txt\""}, + {disposition: ContentDispositionInline, filename: "hello\tworld.txt", header: "inline; filename=\"hello\tworld.txt\""}, + {disposition: ContentDispositionAttachment, filename: "hello\tworld.txt", header: "attachment; filename=\"hello\tworld.txt\""}, + {disposition: ContentDispositionAttachment, filename: "hello\nworld.txt", header: "attachment; filename=hello_world.txt; filename*=utf-8''hello%0Aworld.txt"}, + {disposition: ContentDispositionAttachment, filename: "hello\rworld.txt", header: "attachment; filename=hello_world.txt; filename*=utf-8''hello%0Dworld.txt"}, + } + + // Check the needsEncodingRune replacer ranges except tab that is checked above + // Any change in behavior should fail here + for c := ' '; !needsEncodingRune(c); c++ { + var header string + switch { + case strings.ContainsAny(string(c), ` (),/:;<=>?@[]`): + header = "inline; filename=\"hello" + string(c) + "world.txt\"" + case strings.ContainsAny(string(c), `"\`): + // This document advises against for backslash in quoted form: + // https://datatracker.ietf.org/doc/html/rfc6266#appendix-D + // However the mime package is not generating the filename* in this scenario + header = "inline; filename=\"hello\\" + string(c) + "world.txt\"" + default: + header = "inline; filename=hello" + string(c) + "world.txt" + } + table = append(table, testEntry{ + disposition: ContentDispositionInline, + filename: "hello" + string(c) + "world.txt", + header: header, + }) } for _, entry := range table { From 5dd8e5ed96725bbf645ebf61406d7b394250732e Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Tue, 24 Mar 2026 22:08:41 +0100 Subject: [PATCH 51/78] revert content-type --- routers/api/actions/artifactsv4.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index dfa8658b1c4fe..61c5c9644c3b7 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -303,7 +303,7 @@ func (r *artifactV4Routes) sendProtobufBody(ctx *ArtifactContext, req protorefle ctx.HTTPError(http.StatusInternalServerError, "Error encode response body") return } - ctx.Resp.Header().Set("Content-Type", mime.FormatMediaType("application/json", map[string]string{"charset": "utf-8"})) + ctx.Resp.Header().Set("Content-Type", "application/json;charset=utf-8") ctx.Resp.WriteHeader(http.StatusOK) _, _ = ctx.Resp.Write(resp) } From 6dc705a5658f3358781a3f09a0ee5a222c7496a0 Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Tue, 24 Mar 2026 22:18:35 +0100 Subject: [PATCH 52/78] move from public to httplib --- modules/actions/artifacts.go | 4 +- modules/httplib/content_disposition.go | 58 +++++++++++++++++++ .../content_disposition_test.go} | 2 +- modules/httplib/serve.go | 11 ++-- modules/storage/storage.go | 3 +- routers/common/actions.go | 4 +- routers/web/admin/diagnosis.go | 3 +- routers/web/repo/actions/view.go | 4 +- services/lfs/server.go | 3 +- 9 files changed, 74 insertions(+), 18 deletions(-) create mode 100644 modules/httplib/content_disposition.go rename modules/{public/mime_types_test.go => httplib/content_disposition_test.go} (99%) diff --git a/modules/actions/artifacts.go b/modules/actions/artifacts.go index 344cabe457856..640495e361be9 100644 --- a/modules/actions/artifacts.go +++ b/modules/actions/artifacts.go @@ -10,8 +10,8 @@ import ( "strings" actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/services/context" @@ -25,7 +25,7 @@ func IsArtifactV4(art *actions_model.ActionArtifact) bool { func GetArtifactContentTypeAndDisposition(artifact *actions_model.ActionArtifact) (contentType, contentDisposition string, _ error) { contentType = mime.FormatMediaType(artifact.ContentEncoding, nil) - contentDisposition = public.EncodeContentDisposition(public.ContentDispositionInline, artifact.ArtifactPath) + contentDisposition = httplib.EncodeContentDisposition(httplib.ContentDispositionInline, artifact.ArtifactPath) if contentType == "" || contentDisposition == "" { setting.PanicInDevOrTesting("cannot generate mime headers") return "", "", errors.New("cannot generate mime headers") diff --git a/modules/httplib/content_disposition.go b/modules/httplib/content_disposition.go new file mode 100644 index 0000000000000..b0aeeffe9ed14 --- /dev/null +++ b/modules/httplib/content_disposition.go @@ -0,0 +1,58 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package httplib + +import ( + "fmt" + "mime" + "strings" + + "code.gitea.io/gitea/modules/setting" +) + +type ContentDispositionType string + +const ( + ContentDispositionInline ContentDispositionType = "inline" + ContentDispositionAttachment ContentDispositionType = "attachment" +) + +func needsEncodingRune(b rune) bool { + return (b < ' ' || b > '~') && b != '\t' +} + +// getSafeName replaces all invalid chars in the filename field by underscore +func getSafeName(s string) (_ string, needsEncoding bool) { + var out strings.Builder + for _, b := range s { + if needsEncodingRune(b) { + needsEncoding = true + out.WriteRune('_') + } else { + out.WriteRune(b) + } + } + return out.String(), needsEncoding +} + +// EncodeContentDisposition encodes a correct Content-Disposition Header +func EncodeContentDisposition(t ContentDispositionType, filename string) string { + safeFilename, needsEncoding := getSafeName(filename) + result := mime.FormatMediaType(string(t), map[string]string{"filename": safeFilename}) + // No need for the utf8 encoding + if !needsEncoding { + return result + } + utf8Result := mime.FormatMediaType(string(t), map[string]string{"filename": filename}) + + // The mime package might have unexpected results in other go versions + // Make tests instance fail, otherwise use the default behavior of the go mime package + if !strings.HasPrefix(result, fmt.Sprintf("%s; filename=", string(t))) || !strings.HasPrefix(utf8Result, fmt.Sprintf("%s; filename*=", string(t))) { + setting.PanicInDevOrTesting("Unexpected mime package result %s", result) + return utf8Result + } + + encodedFileName := strings.TrimPrefix(utf8Result, string(t)) + return result + encodedFileName +} diff --git a/modules/public/mime_types_test.go b/modules/httplib/content_disposition_test.go similarity index 99% rename from modules/public/mime_types_test.go rename to modules/httplib/content_disposition_test.go index 693d4e866fbcf..d16c37437b295 100644 --- a/modules/public/mime_types_test.go +++ b/modules/httplib/content_disposition_test.go @@ -1,7 +1,7 @@ // Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package public +package httplib import ( "mime" diff --git a/modules/httplib/serve.go b/modules/httplib/serve.go index ba7058053bda5..0411b83669b28 100644 --- a/modules/httplib/serve.go +++ b/modules/httplib/serve.go @@ -18,7 +18,6 @@ import ( charsetModule "code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/httpcache" - "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/typesniffer" "code.gitea.io/gitea/modules/util" @@ -30,7 +29,7 @@ type ServeHeaderOptions struct { ContentType string // defaults to "application/octet-stream" ContentTypeCharset string ContentLength *int64 - Disposition public.ContentDispositionType // defaults to "attachment" + Disposition ContentDispositionType // defaults to "attachment" Filename string CacheIsPublic bool CacheDuration time.Duration // defaults to 5 minutes @@ -64,10 +63,10 @@ func ServeSetHeaders(w http.ResponseWriter, opts *ServeHeaderOptions) { if opts.Filename != "" { disposition := opts.Disposition if disposition == "" { - disposition = public.ContentDispositionAttachment + disposition = ContentDispositionAttachment } - header.Set("Content-Disposition", public.EncodeContentDisposition(disposition, opts.Filename)) + header.Set("Content-Disposition", EncodeContentDisposition(disposition, opts.Filename)) header.Set("Access-Control-Expose-Headers", "Content-Disposition") } @@ -123,9 +122,9 @@ func setServeHeadersByFile(r *http.Request, w http.ResponseWriter, mineBuf []byt } // TODO: UNIFY-CONTENT-DISPOSITION-FROM-STORAGE - opts.Disposition = public.ContentDispositionInline + opts.Disposition = ContentDispositionInline if sniffedType.IsSvgImage() && !setting.UI.SVG.Enabled { - opts.Disposition = public.ContentDispositionAttachment + opts.Disposition = ContentDispositionAttachment } ServeSetHeaders(w, opts) diff --git a/modules/storage/storage.go b/modules/storage/storage.go index ed995c607651d..6631045808926 100644 --- a/modules/storage/storage.go +++ b/modules/storage/storage.go @@ -12,6 +12,7 @@ import ( "os" "path" + "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/setting" @@ -85,7 +86,7 @@ func prepareServeDirectOptions(optsOptional *ServeDirectOptions, name string) (r // When using ServeDirect, the URL is from the object storage's web server, // it is not the same origin as Gitea server, so it should be safe enough to use "inline" to render the content directly. // If a browser doesn't support the content type to be displayed inline, browser will download with the filename. - ret.ContentDisposition = public.EncodeContentDisposition(public.ContentDispositionInline, name) + ret.ContentDisposition = httplib.EncodeContentDisposition(httplib.ContentDispositionInline, name) } return ret } diff --git a/routers/common/actions.go b/routers/common/actions.go index a443340b526ea..0534956ee6281 100644 --- a/routers/common/actions.go +++ b/routers/common/actions.go @@ -10,7 +10,7 @@ import ( actions_model "code.gitea.io/gitea/models/actions" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/actions" - "code.gitea.io/gitea/modules/public" + "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/context" ) @@ -63,7 +63,7 @@ func DownloadActionsRunJobLogs(ctx *context.Base, ctxRepo *repo_model.Repository ContentLength: &task.LogSize, ContentType: "text/plain", ContentTypeCharset: "utf-8", - Disposition: public.ContentDispositionAttachment, + Disposition: httplib.ContentDispositionAttachment, }) return nil } diff --git a/routers/web/admin/diagnosis.go b/routers/web/admin/diagnosis.go index d6198bfd8447f..aee0b25410548 100644 --- a/routers/web/admin/diagnosis.go +++ b/routers/web/admin/diagnosis.go @@ -10,7 +10,6 @@ import ( "time" "code.gitea.io/gitea/modules/httplib" - "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/tailmsg" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/context" @@ -21,7 +20,7 @@ func MonitorDiagnosis(ctx *context.Context) { httplib.ServeSetHeaders(ctx.Resp, &httplib.ServeHeaderOptions{ ContentType: "application/zip", - Disposition: public.ContentDispositionAttachment, + Disposition: httplib.ContentDispositionAttachment, Filename: fmt.Sprintf("gitea-diagnosis-%s.zip", time.Now().Format("20060102-150405")), }) diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 2e9ab7636c7c6..fc8e689d4d278 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -24,8 +24,8 @@ import ( "code.gitea.io/gitea/modules/actions" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/translation" @@ -726,7 +726,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) { return } - ctx.Resp.Header().Set("Content-Disposition", public.EncodeContentDisposition(public.ContentDispositionAttachment, artifactName+".zip")) + ctx.Resp.Header().Set("Content-Disposition", httplib.EncodeContentDisposition(httplib.ContentDispositionAttachment, artifactName+".zip")) // Artifacts using the v1-v3 backend are stored as multiple individual files per artifact on the backend // Those need to be zipped for download diff --git a/services/lfs/server.go b/services/lfs/server.go index a51c89bbcb6f6..f57b92adabc6b 100644 --- a/services/lfs/server.go +++ b/services/lfs/server.go @@ -30,7 +30,6 @@ import ( "code.gitea.io/gitea/modules/json" lfs_module "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/services/context" @@ -173,7 +172,7 @@ func DownloadHandler(ctx *context.Context) { if len(filename) > 0 { decodedFilename, err := base64.RawURLEncoding.DecodeString(filename) if err == nil { - ctx.Resp.Header().Set("Content-Disposition", public.EncodeContentDisposition(public.ContentDispositionAttachment, string(decodedFilename))) + ctx.Resp.Header().Set("Content-Disposition", httplib.EncodeContentDisposition(httplib.ContentDispositionAttachment, string(decodedFilename))) ctx.Resp.Header().Set("Access-Control-Expose-Headers", "Content-Disposition") } } From e9bc1cab7f7e0b1d73b7e60dee41eb1d0ae7fb4d Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Tue, 24 Mar 2026 22:45:44 +0100 Subject: [PATCH 53/78] Unify artifact storage path --- routers/api/actions/artifacts_chunks.go | 20 ++++++++++++-------- routers/api/actions/artifactsv4.go | 2 +- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/routers/api/actions/artifacts_chunks.go b/routers/api/actions/artifacts_chunks.go index 86a51d6ca64bc..09d7fe1658f81 100644 --- a/routers/api/actions/artifacts_chunks.go +++ b/routers/api/actions/artifacts_chunks.go @@ -285,6 +285,17 @@ func mergeChunksForRun(ctx *ArtifactContext, st storage.ObjectStorage, runID int return nil } +func generateArtifactStoragePath(artifact *actions.ActionArtifact) string { + // 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" + } + + return fmt.Sprintf("%d/%d/%d.%s", artifact.RunID%255, artifact.ID%255, time.Now().UnixNano(), extension) +} + 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 @@ -335,15 +346,8 @@ func mergeChunksForArtifact(ctx *ArtifactContext, chunks []*chunkFileItem, st st mergedReader = io.TeeReader(mergedReader, hashSha256) } - // 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) + storagePath := generateArtifactStoragePath(artifact) written, err := st.Save(storagePath, mergedReader, artifact.FileCompressedSize) if err != nil { return fmt.Errorf("save merged file error: %v", err) diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index 61c5c9644c3b7..98a07f224a749 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -349,7 +349,7 @@ func (r *artifactV4Routes) createArtifact(ctx *ArtifactContext) { var respData CreateArtifactResponse if setting.Actions.ArtifactStorage.ServeDirect() && setting.Actions.ArtifactStorage.Type == setting.AzureBlobStorageType { - storagePath := fmt.Sprintf("%d/%d/%d.blob", artifact.RunID%255, artifact.ID%255, time.Now().UnixNano()) + storagePath := generateArtifactStoragePath(artifact) if artifact.StoragePath != "" { _ = storage.ActionsArtifacts.Delete(artifact.StoragePath) } From 70cc32a822ebf0c2c38045403aa33d94955565db Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Tue, 24 Mar 2026 22:47:17 +0100 Subject: [PATCH 54/78] Split finalizeArtifact finalizer --- routers/api/actions/artifactsv4.go | 145 +++++++++++++++-------------- 1 file changed, 76 insertions(+), 69 deletions(-) diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index 98a07f224a749..ed1cb6c8a694b 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -485,91 +485,98 @@ func (r *artifactV4Routes) finalizeArtifact(ctx *ArtifactContext) { return } - checksum := "" - if req.Hash != nil { - checksum = req.Hash.Value + if setting.Actions.ArtifactStorage.ServeDirect() && setting.Actions.ArtifactStorage.Type == setting.AzureBlobStorageType { + r.finalizeAzureServeDirect(ctx, &req, artifact) + } else { + r.finalizeDefaultArtifact(ctx, &req, artifact, runID) } - if setting.Actions.ArtifactStorage.ServeDirect() && setting.Actions.ArtifactStorage.Type == setting.AzureBlobStorageType { - checksumValue, found := strings.CutPrefix(checksum, "sha256:") - var actualLength int64 - if found { - hashSha256 := sha256.New() - obj, err := storage.ActionsArtifacts.Open(artifact.StoragePath) - if err != nil { - log.Error("Error read block: %v", err) - ctx.HTTPError(http.StatusInternalServerError, "Error read block") - return - } - defer obj.Close() - actualLength, err = io.Copy(hashSha256, obj) - if err != nil { - log.Error("Error read block: %v", err) - ctx.HTTPError(http.StatusInternalServerError, "Error read block") - return - } - rawChecksum := hashSha256.Sum(nil) - actualChecksum := hex.EncodeToString(rawChecksum) - if checksumValue != actualChecksum { - log.Error("Error merge chunks: checksum mismatch") - ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks: checksum mismatch") - return - } - } else { - fi, err := storage.ActionsArtifacts.Stat(artifact.StoragePath) - if err != nil { - log.Error("Error stat block: %v", err) - ctx.HTTPError(http.StatusInternalServerError, "Error stat block") - return - } - actualLength = fi.Size() - } + // Return on finalize error + if ctx.Written() { + return + } - if req.Size != actualLength { - log.Error("Error merge chunks: length mismatch") - ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks: length mismatch") - return - } + respData := FinalizeArtifactResponse{ + Ok: true, + ArtifactId: artifact.ID, + } + r.sendProtobufBody(ctx, &respData) +} + +func (r *artifactV4Routes) finalizeDefaultArtifact(ctx *ArtifactContext, req *FinalizeArtifactRequest, artifact *actions_model.ActionArtifact, runID int64) { + blockList, blockListErr := r.readBlockList(runID, artifact.ID) + chunks, err := listOrderedChunksForArtifact(r.fs, runID, artifact.ID, blockList) + if err != nil { + log.Error("Error list chunks: %v", errors.Join(blockListErr, err)) + ctx.HTTPError(http.StatusInternalServerError, "Error list chunks") + return + } + artifact.FileSize = chunks[len(chunks)-1].End + 1 + artifact.FileCompressedSize = chunks[len(chunks)-1].End + 1 + + if req.Size != artifact.FileSize { + log.Error("Error merge chunks size mismatch") + ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks size mismatch") + return + } + + if err := mergeChunksForArtifact(ctx, chunks, r.fs, artifact, req.GetHash().GetValue()); err != nil { + log.Error("Error merge chunks: %v", err) + ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks") + return + } +} - // Update artifact metadata and status now that the upload is confirmed. - artifact.FileSize = actualLength - artifact.FileCompressedSize = actualLength - artifact.Status = actions_model.ArtifactStatusUploadConfirmed - if err := actions_model.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil { - log.Error("Error UpdateArtifactByID: %v", err) - ctx.HTTPError(http.StatusInternalServerError, "Error UpdateArtifactByID") +func (r *artifactV4Routes) finalizeAzureServeDirect(ctx *ArtifactContext, req *FinalizeArtifactRequest, artifact *actions_model.ActionArtifact) { + checksumValue, found := strings.CutPrefix(req.GetHash().GetValue(), "sha256:") + var actualLength int64 + if found { + hashSha256 := sha256.New() + obj, err := storage.ActionsArtifacts.Open(artifact.StoragePath) + if err != nil { + log.Error("Error read block: %v", err) + ctx.HTTPError(http.StatusInternalServerError, "Error read block") return } - } else { - var chunks []*chunkFileItem - blockList, blockListErr := r.readBlockList(runID, artifact.ID) - chunks, err = listOrderedChunksForArtifact(r.fs, runID, artifact.ID, blockList) + defer obj.Close() + actualLength, err = io.Copy(hashSha256, obj) if err != nil { - log.Error("Error list chunks: %v", errors.Join(blockListErr, err)) - ctx.HTTPError(http.StatusInternalServerError, "Error list chunks") + log.Error("Error read block: %v", err) + ctx.HTTPError(http.StatusInternalServerError, "Error read block") return } - artifact.FileSize = chunks[len(chunks)-1].End + 1 - artifact.FileCompressedSize = chunks[len(chunks)-1].End + 1 - - if req.Size != artifact.FileSize { - log.Error("Error merge chunks size mismatch") - ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks size mismatch") + rawChecksum := hashSha256.Sum(nil) + actualChecksum := hex.EncodeToString(rawChecksum) + if checksumValue != actualChecksum { + log.Error("Error merge chunks: checksum mismatch") + ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks: checksum mismatch") return } - - 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") + } else { + fi, err := storage.ActionsArtifacts.Stat(artifact.StoragePath) + if err != nil { + log.Error("Error stat block: %v", err) + ctx.HTTPError(http.StatusInternalServerError, "Error stat block") return } + actualLength = fi.Size() } - respData := FinalizeArtifactResponse{ - Ok: true, - ArtifactId: artifact.ID, + if req.Size != actualLength { + log.Error("Error merge chunks: length mismatch") + ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks: length mismatch") + return + } + + // Update artifact metadata and status now that the upload is confirmed. + artifact.FileSize = actualLength + artifact.FileCompressedSize = actualLength + artifact.Status = actions_model.ArtifactStatusUploadConfirmed + if err := actions_model.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil { + log.Error("Error UpdateArtifactByID: %v", err) + ctx.HTTPError(http.StatusInternalServerError, "Error UpdateArtifactByID") + return } - r.sendProtobufBody(ctx, &respData) } func (r *artifactV4Routes) listArtifacts(ctx *ArtifactContext) { From 5331f99daaf6b340e8ad098e8cde906063a1a043 Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Tue, 24 Mar 2026 23:16:10 +0100 Subject: [PATCH 55/78] fix lint --- modules/httplib/content_disposition.go | 3 +- modules/public/mime_types.go | 50 -------------------------- 2 files changed, 1 insertion(+), 52 deletions(-) diff --git a/modules/httplib/content_disposition.go b/modules/httplib/content_disposition.go index b0aeeffe9ed14..ac44c1a3d7229 100644 --- a/modules/httplib/content_disposition.go +++ b/modules/httplib/content_disposition.go @@ -4,7 +4,6 @@ package httplib import ( - "fmt" "mime" "strings" @@ -48,7 +47,7 @@ func EncodeContentDisposition(t ContentDispositionType, filename string) string // The mime package might have unexpected results in other go versions // Make tests instance fail, otherwise use the default behavior of the go mime package - if !strings.HasPrefix(result, fmt.Sprintf("%s; filename=", string(t))) || !strings.HasPrefix(utf8Result, fmt.Sprintf("%s; filename*=", string(t))) { + if !strings.HasPrefix(result, string(t)+"; filename=") || !strings.HasPrefix(utf8Result, string(t)+"; filename*=") { setting.PanicInDevOrTesting("Unexpected mime package result %s", result) return utf8Result } diff --git a/modules/public/mime_types.go b/modules/public/mime_types.go index ca3b314c2560d..fef85d77cbefa 100644 --- a/modules/public/mime_types.go +++ b/modules/public/mime_types.go @@ -4,11 +4,7 @@ package public import ( - "fmt" - "mime" "strings" - - "code.gitea.io/gitea/modules/setting" ) // wellKnownMimeTypesLower comes from Golang's builtin mime package: `builtinTypesLower`, see the comment of DetectWellKnownMimeType @@ -44,49 +40,3 @@ func DetectWellKnownMimeType(ext string) string { ext = strings.ToLower(ext) return wellKnownMimeTypesLower[ext] } - -type ContentDispositionType string - -const ( - ContentDispositionInline ContentDispositionType = "inline" - ContentDispositionAttachment ContentDispositionType = "attachment" -) - -func needsEncodingRune(b rune) bool { - return (b < ' ' || b > '~') && b != '\t' -} - -// getSafeName replaces all invalid chars in the filename field by underscore -func getSafeName(s string) (_ string, needsEncoding bool) { - var out strings.Builder - for _, b := range s { - if needsEncodingRune(b) { - needsEncoding = true - out.WriteRune('_') - } else { - out.WriteRune(b) - } - } - return out.String(), needsEncoding -} - -// EncodeContentDisposition encodes a correct Content-Disposition Header -func EncodeContentDisposition(t ContentDispositionType, filename string) string { - safeFilename, needsEncoding := getSafeName(filename) - result := mime.FormatMediaType(string(t), map[string]string{"filename": safeFilename}) - // No need for the utf8 encoding - if !needsEncoding { - return result - } - utf8Result := mime.FormatMediaType(string(t), map[string]string{"filename": filename}) - - // The mime package might have unexpected results in other go versions - // Make tests instance fail, otherwise use the default behavior of the go mime package - if !strings.HasPrefix(result, fmt.Sprintf("%s; filename=", string(t))) || !strings.HasPrefix(utf8Result, fmt.Sprintf("%s; filename*=", string(t))) { - setting.PanicInDevOrTesting("Unexpected mime package result %s", result) - return utf8Result - } - - encodedFileName := strings.TrimPrefix(utf8Result, string(t)) - return result + encodedFileName -} From a4aad6554746e98cf0d56fa27fc4869b7c152cf0 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 25 Mar 2026 17:19:38 +0800 Subject: [PATCH 56/78] clarify ContentEncoding --- models/actions/artifact.go | 28 ++++++++++++++++--------- routers/api/actions/artifacts.go | 2 +- routers/api/actions/artifacts_chunks.go | 2 +- routers/web/repo/actions/view.go | 2 +- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/models/actions/artifact.go b/models/actions/artifact.go index d3197a728587a..460230649a1b7 100644 --- a/models/actions/artifact.go +++ b/models/actions/artifact.go @@ -53,6 +53,8 @@ func init() { db.RegisterModel(new(ActionArtifact)) } +const ContentEncodingV4Gzip = "gzip" + // ActionArtifact is a file that is stored in the artifact storage. type ActionArtifact struct { ID int64 `xorm:"pk autoincr"` @@ -61,16 +63,22 @@ type ActionArtifact struct { RepoID int64 `xorm:"index"` OwnerID int64 CommitSHA string - StoragePath string // The path to the artifact in the storage - FileSize int64 // The size of the artifact in bytes - FileCompressedSize int64 // The size of the artifact in bytes after gzip compression - ContentEncoding string // The content encoding of the artifact - ArtifactPath string `xorm:"index unique(runid_name_path)"` // The path to the artifact when runner uploads it - ArtifactName string `xorm:"index unique(runid_name_path)"` // The name of the artifact when runner uploads it - Status ArtifactStatus `xorm:"index"` // The status of the artifact, uploading, expired or need-delete - CreatedUnix timeutil.TimeStamp `xorm:"created"` - UpdatedUnix timeutil.TimeStamp `xorm:"updated index"` - ExpiredUnix timeutil.TimeStamp `xorm:"index"` // The time when the artifact will be expired + StoragePath string // The path to the artifact in the storage + FileSize int64 // The size of the artifact in bytes + FileCompressedSize int64 // The size of the artifact in bytes after gzip compression + + // The content encoding or content type (abused?) of the artifact + // * empty or null: legacy (v3) uncompressed content + // * magic string "gzip": v4 gzip content, the content itself is a gzip file, so serve it directly + // * mime type like "application/zip" for "Content-Type" + ContentEncoding string + + ArtifactPath string `xorm:"index unique(runid_name_path)"` // The path to the artifact when runner uploads it + ArtifactName string `xorm:"index unique(runid_name_path)"` // The name of the artifact when runner uploads it + Status ArtifactStatus `xorm:"index"` // The status of the artifact, uploading, expired or need-delete + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated index"` + ExpiredUnix timeutil.TimeStamp `xorm:"index"` // The time when the artifact will be expired } func CreateArtifact(ctx context.Context, t *ActionTask, artifactName, artifactPath string, expiredDays int64) (*ActionArtifact, error) { diff --git a/routers/api/actions/artifacts.go b/routers/api/actions/artifacts.go index 76facd769f296..74309bf169a1e 100644 --- a/routers/api/actions/artifacts.go +++ b/routers/api/actions/artifacts.go @@ -492,7 +492,7 @@ func (ar artifactRoutes) downloadArtifact(ctx *ArtifactContext) { defer fd.Close() // if artifact is compressed, set content-encoding header to gzip - if artifact.ContentEncoding == "gzip" { + if artifact.ContentEncoding == actions.ContentEncodingV4Gzip { 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) diff --git a/routers/api/actions/artifacts_chunks.go b/routers/api/actions/artifacts_chunks.go index 09d7fe1658f81..31317f5155b15 100644 --- a/routers/api/actions/artifacts_chunks.go +++ b/routers/api/actions/artifacts_chunks.go @@ -289,7 +289,7 @@ func generateArtifactStoragePath(artifact *actions.ActionArtifact) string { // 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" { + if artifact.ContentEncoding == actions.ContentEncodingV4Gzip { extension = "chunk.gz" } diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index fc8e689d4d278..8fdd484c16c59 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -740,7 +740,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) { } var r io.ReadCloser - if art.ContentEncoding == "gzip" { + if art.ContentEncoding == actions_model.ContentEncodingV4Gzip { r, err = gzip.NewReader(f) if err != nil { ctx.ServerError("gzip.NewReader", err) From 809822ce5d0c30d0c685e7a0c31c93bb4a82db11 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 25 Mar 2026 17:30:27 +0800 Subject: [PATCH 57/78] clarify ContentEncoding --- models/actions/artifact.go | 20 ++++++++++++------- modules/actions/artifacts.go | 4 ++-- routers/api/actions/artifacts.go | 4 ++-- routers/api/actions/artifacts_chunks.go | 2 +- routers/api/actions/artifactsv4.go | 11 ++++------ routers/web/repo/actions/view.go | 2 +- .../api_actions_artifact_v4_test.go | 12 +++++------ 7 files changed, 29 insertions(+), 26 deletions(-) diff --git a/models/actions/artifact.go b/models/actions/artifact.go index 460230649a1b7..504b2bbf7cb15 100644 --- a/models/actions/artifact.go +++ b/models/actions/artifact.go @@ -53,7 +53,10 @@ func init() { db.RegisterModel(new(ActionArtifact)) } -const ContentEncodingV4Gzip = "gzip" +const ( + ContentEncodingV4Gzip = "gzip" + ContentTypeZip = "application/zip" +) // ActionArtifact is a file that is stored in the artifact storage. type ActionArtifact struct { @@ -67,11 +70,13 @@ type ActionArtifact struct { FileSize int64 // The size of the artifact in bytes FileCompressedSize int64 // The size of the artifact in bytes after gzip compression - // The content encoding or content type (abused?) of the artifact - // * empty or null: legacy (v3) uncompressed content - // * magic string "gzip": v4 gzip content, the content itself is a gzip file, so serve it directly - // * mime type like "application/zip" for "Content-Type" - ContentEncoding string + // The content encoding or content type of the artifact + // * empty or null: legacy (v3) uncompressed content? + // * magic string "gzip" (ContentEncodingV4Gzip): v4 gzip content, the content itself is a gzip file, so serve it directly? + // * mime type like for "Content-Type": + // * "application/zip" (ContentTypeZip), seems to be an abuse, fortunately there is no conflict, and it won't cause problems? + // * "application/pdf", "text/html", etc.: real content type of the artifact + ContentEncodingOrType string ArtifactPath string `xorm:"index unique(runid_name_path)"` // The path to the artifact when runner uploads it ArtifactName string `xorm:"index unique(runid_name_path)"` // The name of the artifact when runner uploads it @@ -164,7 +169,8 @@ func (opts FindArtifactsOptions) ToConds() builder.Cond { } if opts.FinalizedArtifactsV4 { cond = cond.And(builder.Eq{"status": ArtifactStatusUploadConfirmed}.Or(builder.Eq{"status": ArtifactStatusExpired})) - cond = cond.And(builder.Like{"content_encoding", "/"}) + // see the comment of ActionArtifact.ContentEncodingOrType: "*/*" means the field is a content type + cond = cond.And(builder.Like{"content_encoding", "%/%"}) } return cond diff --git a/modules/actions/artifacts.go b/modules/actions/artifacts.go index 640495e361be9..b7b79899ca46c 100644 --- a/modules/actions/artifacts.go +++ b/modules/actions/artifacts.go @@ -20,11 +20,11 @@ import ( // Artifacts using the v4 backend are stored as a single combined zip file per artifact on the backend // The v4 backend ensures ContentEncoding contains a slash (otherwise this uses application/zip instead of the custom mime type), which is not the case for the old backend func IsArtifactV4(art *actions_model.ActionArtifact) bool { - return strings.Contains(art.ContentEncoding, "/") + return strings.Contains(art.ContentEncodingOrType, "/") } func GetArtifactContentTypeAndDisposition(artifact *actions_model.ActionArtifact) (contentType, contentDisposition string, _ error) { - contentType = mime.FormatMediaType(artifact.ContentEncoding, nil) + contentType = mime.FormatMediaType(artifact.ContentEncodingOrType, nil) contentDisposition = httplib.EncodeContentDisposition(httplib.ContentDispositionInline, artifact.ArtifactPath) if contentType == "" || contentDisposition == "" { setting.PanicInDevOrTesting("cannot generate mime headers") diff --git a/routers/api/actions/artifacts.go b/routers/api/actions/artifacts.go index 74309bf169a1e..c4d51cd821ea8 100644 --- a/routers/api/actions/artifacts.go +++ b/routers/api/actions/artifacts.go @@ -282,7 +282,7 @@ func (ar artifactRoutes) uploadArtifact(ctx *ArtifactContext) { artifact.FileCompressedSize != chunksTotalSize { artifact.FileSize = fileRealTotalSize artifact.FileCompressedSize = chunksTotalSize - artifact.ContentEncoding = ctx.Req.Header.Get("Content-Encoding") + artifact.ContentEncodingOrType = 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") @@ -492,7 +492,7 @@ func (ar artifactRoutes) downloadArtifact(ctx *ArtifactContext) { defer fd.Close() // if artifact is compressed, set content-encoding header to gzip - if artifact.ContentEncoding == actions.ContentEncodingV4Gzip { + if artifact.ContentEncodingOrType == actions.ContentEncodingV4Gzip { 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) diff --git a/routers/api/actions/artifacts_chunks.go b/routers/api/actions/artifacts_chunks.go index 31317f5155b15..556a70918aa82 100644 --- a/routers/api/actions/artifacts_chunks.go +++ b/routers/api/actions/artifacts_chunks.go @@ -289,7 +289,7 @@ func generateArtifactStoragePath(artifact *actions.ActionArtifact) string { // 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 == actions.ContentEncodingV4Gzip { + if artifact.ContentEncodingOrType == actions.ContentEncodingV4Gzip { extension = "chunk.gz" } diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index ed1cb6c8a694b..11c0c204237e2 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -119,10 +119,7 @@ import ( "xorm.io/builder" ) -const ( - ArtifactV4RouteBase = "/twirp/github.actions.results.api.v1.ArtifactService" - ArtifactV4ContentEncoding = "application/zip" -) +const ArtifactV4RouteBase = "/twirp/github.actions.results.api.v1.ArtifactService" type artifactV4Routes struct { prefix string @@ -331,8 +328,8 @@ func (r *artifactV4Routes) createArtifact(ctx *ArtifactContext) { encoding, _, _ = mime.ParseMediaType(encoding) } fileName := artifactName - if !strings.Contains(encoding, "/") || strings.EqualFold(encoding, ArtifactV4ContentEncoding) && !strings.HasSuffix(fileName, ".zip") { - encoding = ArtifactV4ContentEncoding + if !strings.Contains(encoding, "/") || strings.EqualFold(encoding, actions_model.ContentTypeZip) && !strings.HasSuffix(fileName, ".zip") { + encoding = actions_model.ContentTypeZip fileName = artifactName + ".zip" } // create or get artifact with name and path @@ -342,7 +339,7 @@ func (r *artifactV4Routes) createArtifact(ctx *ArtifactContext) { ctx.HTTPError(http.StatusInternalServerError, "Error create or get artifact") return } - artifact.ContentEncoding = encoding + artifact.ContentEncodingOrType = encoding artifact.FileSize = 0 artifact.FileCompressedSize = 0 diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 8fdd484c16c59..edc5e48223f4d 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -740,7 +740,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) { } var r io.ReadCloser - if art.ContentEncoding == actions_model.ContentEncodingV4Gzip { + if art.ContentEncodingOrType == actions_model.ContentEncodingV4Gzip { r, err = gzip.NewReader(f) if err != nil { ctx.ServerError("gzip.NewReader", err) diff --git a/tests/integration/api_actions_artifact_v4_test.go b/tests/integration/api_actions_artifact_v4_test.go index 2654e7fb28953..c0cd4cdebd271 100644 --- a/tests/integration/api_actions_artifact_v4_test.go +++ b/tests/integration/api_actions_artifact_v4_test.go @@ -202,9 +202,9 @@ func TestActionsArtifactV4UploadSingleFile(t *testing.T) { artifact := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionArtifact{ID: finalizeResp.ArtifactId}) if entry.contentType != "" { - assert.Equal(t, entry.contentType, artifact.ContentEncoding) + assert.Equal(t, entry.contentType, artifact.ContentEncodingOrType) } else { - assert.Equal(t, actions.ArtifactV4ContentEncoding, artifact.ContentEncoding) + assert.Equal(t, "application/zip", artifact.ContentEncodingOrType) } if entry.path != "" { assert.Equal(t, entry.path, artifact.ArtifactPath) @@ -485,9 +485,9 @@ func TestActionsArtifactV4UploadSingleFileWithChunksOutOfOrder(t *testing.T) { artifact := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionArtifact{ID: finalizeResp.ArtifactId}) if entry.contentType != "" { - assert.Equal(t, entry.contentType, artifact.ContentEncoding) + assert.Equal(t, entry.contentType, artifact.ContentEncodingOrType) } else { - assert.Equal(t, actions.ArtifactV4ContentEncoding, artifact.ContentEncoding) + assert.Equal(t, "application/zip", artifact.ContentEncodingOrType) } assert.Equal(t, actions_model.ArtifactStatusUploadConfirmed, artifact.Status) assert.Equal(t, int64(2048), artifact.FileSize) @@ -510,10 +510,10 @@ func TestActionsArtifactV4DownloadSingle(t *testing.T) { ContentType string ContentDisposition string }{ - {Name: "Download-Zip", ArtifactName: "artifact-v4-download", FileName: "artifact-v4-download.zip", ContentType: actions.ArtifactV4ContentEncoding}, + {Name: "Download-Zip", ArtifactName: "artifact-v4-download", FileName: "artifact-v4-download.zip", ContentType: "application/zip"}, {Name: "Download-Pdf", ArtifactName: "report.pdf", FileName: "report.pdf", ContentType: "application/pdf"}, {Name: "Download-Html", ArtifactName: "report.html", FileName: "report.html", ContentType: "application/html"}, - {Name: "ServeDirect-Zip", ArtifactName: "artifact-v4-download", FileName: "artifact-v4-download.zip", ContentType: actions.ArtifactV4ContentEncoding, ServeDirect: true}, + {Name: "ServeDirect-Zip", ArtifactName: "artifact-v4-download", FileName: "artifact-v4-download.zip", ContentType: "application/zip", ServeDirect: true}, {Name: "ServeDirect-Pdf", ArtifactName: "report.pdf", FileName: "report.pdf", ContentType: "application/pdf", ServeDirect: true}, {Name: "ServeDirect-Html", ArtifactName: "report.html", FileName: "report.html", ContentType: "application/html", ServeDirect: true}, } From 3606c7326bf6dc31db776eecd92b41468d40675d Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 25 Mar 2026 17:38:57 +0800 Subject: [PATCH 58/78] fix defer --- routers/web/repo/actions/view.go | 34 ++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index edc5e48223f4d..9613746884d5e 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -717,6 +717,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) { } } + // FIXME: what if IsArtifactV4 but have multiple artifacts? if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) { err := actions.DownloadArtifactV4(ctx.Base, artifacts[0]) if err != nil { @@ -730,34 +731,37 @@ func ArtifactsDownloadView(ctx *context_module.Context) { // Artifacts using the v1-v3 backend are stored as multiple individual files per artifact on the backend // Those need to be zipped for download - writer := zip.NewWriter(ctx.Resp) - defer writer.Close() - for _, art := range artifacts { + zipWriter := zip.NewWriter(ctx.Resp) + defer zipWriter.Close() + + writeArtifactToZip := func(art *actions_model.ActionArtifact) error { f, err := storage.ActionsArtifacts.Open(art.StoragePath) if err != nil { - ctx.ServerError("ActionsArtifacts.Open", err) - return + return fmt.Errorf("ActionsArtifacts.Open: %w", err) } + defer f.Close() - var r io.ReadCloser + var r io.ReadCloser = f if art.ContentEncodingOrType == actions_model.ContentEncodingV4Gzip { r, err = gzip.NewReader(f) if err != nil { - ctx.ServerError("gzip.NewReader", err) - return + return fmt.Errorf("gzip.NewReader: %w", err) } - } else { - r = f } defer r.Close() - w, err := writer.Create(art.ArtifactPath) + w, err := zipWriter.Create(art.ArtifactPath) if err != nil { - ctx.ServerError("writer.Create", err) - return + return fmt.Errorf("zipWriter.Create: %w", err) } - if _, err := io.Copy(w, r); err != nil { - ctx.ServerError("io.Copy", err) + _, err = io.Copy(w, r) + return fmt.Errorf("io.Copy: %w", err) + } + + for _, art := range artifacts { + err := writeArtifactToZip(art) + if err != nil { + ctx.ServerError("writeArtifactToZip", err) return } } From d7006b8e0101a9f035db3c6596b7ad3229c0de99 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 25 Mar 2026 18:11:22 +0800 Subject: [PATCH 59/78] clean serve file opts --- modules/actions/artifacts.go | 57 +++++------------ modules/httplib/content_disposition.go | 2 + modules/httplib/serve.go | 78 ++++++++++-------------- routers/api/actions/artifactsv4.go | 2 +- routers/common/actions.go | 5 +- routers/common/serve.go | 6 +- routers/web/admin/diagnosis.go | 8 +-- services/context/base.go | 4 +- services/repository/archiver/archiver.go | 2 +- 9 files changed, 61 insertions(+), 103 deletions(-) diff --git a/modules/actions/artifacts.go b/modules/actions/artifacts.go index b7b79899ca46c..1f940a4cee392 100644 --- a/modules/actions/artifacts.go +++ b/modules/actions/artifacts.go @@ -4,9 +4,8 @@ package actions import ( - "errors" - "mime" "net/http" + "path" "strings" actions_model "code.gitea.io/gitea/models/actions" @@ -17,27 +16,16 @@ import ( "code.gitea.io/gitea/services/context" ) -// Artifacts using the v4 backend are stored as a single combined zip file per artifact on the backend -// The v4 backend ensures ContentEncoding contains a slash (otherwise this uses application/zip instead of the custom mime type), which is not the case for the old backend +// IsArtifactV4 detects whether the artifact is likely from v4. +// V4 backend stores the files as a single combined zip file per artifact, and ensures ContentEncoding contains a slash +// (otherwise this uses application/zip instead of the custom mime type), which is not the case for the old backend. func IsArtifactV4(art *actions_model.ActionArtifact) bool { return strings.Contains(art.ContentEncodingOrType, "/") } -func GetArtifactContentTypeAndDisposition(artifact *actions_model.ActionArtifact) (contentType, contentDisposition string, _ error) { - contentType = mime.FormatMediaType(artifact.ContentEncodingOrType, nil) - contentDisposition = httplib.EncodeContentDisposition(httplib.ContentDispositionInline, artifact.ArtifactPath) - if contentType == "" || contentDisposition == "" { - setting.PanicInDevOrTesting("cannot generate mime headers") - return "", "", errors.New("cannot generate mime headers") - } - return contentType, contentDisposition, nil -} - -func GetArtifactV4ServeDirectURL(ctx *context.Base, art *actions_model.ActionArtifact, method string) (string, error) { - contentType, contentDisposition, err := GetArtifactContentTypeAndDisposition(art) - if err != nil { - return "", err - } +func GetArtifactV4ServeDirectURL(art *actions_model.ActionArtifact, method string) (string, error) { + contentType := art.ContentEncodingOrType + contentDisposition := httplib.EncodeContentDisposition(httplib.ContentDispositionInline, path.Base(art.ArtifactPath)) u, err := storage.ActionsArtifacts.ServeDirectURL(art.StoragePath, art.ArtifactPath, method, &storage.ServeDirectOptions{ ContentType: contentType, ContentDisposition: contentDisposition, @@ -51,7 +39,7 @@ func GetArtifactV4ServeDirectURL(ctx *context.Base, art *actions_model.ActionArt func DownloadArtifactV4ServeDirectOnly(ctx *context.Base, art *actions_model.ActionArtifact) (bool, error) { if setting.Actions.ArtifactStorage.ServeDirect() { - u, err := GetArtifactV4ServeDirectURL(ctx, art, ctx.Req.Method) + u, err := GetArtifactV4ServeDirectURL(art, ctx.Req.Method) if u != "" && err == nil { ctx.Redirect(u, http.StatusFound) return true, nil @@ -67,28 +55,13 @@ func DownloadArtifactV4Fallback(ctx *context.Base, art *actions_model.ActionArti } defer f.Close() - contentType, contentDisposition, err := GetArtifactContentTypeAndDisposition(art) - if err != nil { - return err - } - - mediaType, _, err := mime.ParseMediaType(contentType) - if err != nil { - return err - } - - ctx.Resp.Header().Set("Content-Type", contentType) - ctx.Resp.Header().Set("Content-Disposition", contentDisposition) - - switch mediaType { - case "application/pdf": - // HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context, it seems fine to render it inline - ctx.Resp.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") - default: - // Disable script execution of html files, since we serve the file from the same domain as gitea - ctx.Resp.Header().Set("Content-Security-Policy", "sandbox; style-src 'unsafe-inline'; default-src 'none';") - } - http.ServeContent(ctx.Resp, ctx.Req, art.ArtifactPath, art.CreatedUnix.AsLocalTime(), f) + contentType := art.ContentEncodingOrType + contentLength := int64(-1) // do we know the content length (by artifact)? + httplib.ServeContentByReader(ctx.Req, ctx.Resp, contentLength, f, httplib.ServeHeaderOptions{ + Filename: path.Base(art.ArtifactPath), + ContentType: contentType, + ContentDisposition: httplib.ContentDispositionInline, + }) return nil } diff --git a/modules/httplib/content_disposition.go b/modules/httplib/content_disposition.go index ac44c1a3d7229..ff65104077c18 100644 --- a/modules/httplib/content_disposition.go +++ b/modules/httplib/content_disposition.go @@ -5,6 +5,7 @@ package httplib import ( "mime" + "path" "strings" "code.gitea.io/gitea/modules/setting" @@ -37,6 +38,7 @@ func getSafeName(s string) (_ string, needsEncoding bool) { // EncodeContentDisposition encodes a correct Content-Disposition Header func EncodeContentDisposition(t ContentDispositionType, filename string) string { + filename = path.Base(filename) safeFilename, needsEncoding := getSafeName(filename) result := mime.FormatMediaType(string(t), map[string]string{"filename": safeFilename}) // No need for the utf8 encoding diff --git a/modules/httplib/serve.go b/modules/httplib/serve.go index 0411b83669b28..ee882cc665fdf 100644 --- a/modules/httplib/serve.go +++ b/modules/httplib/serve.go @@ -26,18 +26,19 @@ import ( ) type ServeHeaderOptions struct { - ContentType string // defaults to "application/octet-stream" - ContentTypeCharset string - ContentLength *int64 - Disposition ContentDispositionType // defaults to "attachment" + ContentType string // defaults to "application/octet-stream" + ContentLength *int64 + Filename string - CacheIsPublic bool - CacheDuration time.Duration // defaults to 5 minutes - LastModified time.Time + ContentDisposition ContentDispositionType + + CacheIsPublic bool + CacheDuration time.Duration // defaults to 5 minutes + LastModified time.Time } // ServeSetHeaders sets necessary content serve headers -func ServeSetHeaders(w http.ResponseWriter, opts *ServeHeaderOptions) { +func ServeSetHeaders(w http.ResponseWriter, opts ServeHeaderOptions) { header := w.Header() skipCompressionExts := container.SetOf(".gz", ".bz2", ".zip", ".xz", ".zst", ".deb", ".apk", ".jar", ".png", ".jpg", ".webp") @@ -45,14 +46,7 @@ func ServeSetHeaders(w http.ResponseWriter, opts *ServeHeaderOptions) { w.Header().Add(gzhttp.HeaderNoCompression, "1") } - contentType := typesniffer.MimeTypeApplicationOctetStream - if opts.ContentType != "" { - if opts.ContentTypeCharset != "" { - contentType = opts.ContentType + "; charset=" + strings.ToLower(opts.ContentTypeCharset) - } else { - contentType = opts.ContentType - } - } + contentType := util.IfZero(opts.ContentType, typesniffer.MimeTypeApplicationOctetStream) header.Set("Content-Type", contentType) header.Set("X-Content-Type-Options", "nosniff") @@ -60,13 +54,17 @@ func ServeSetHeaders(w http.ResponseWriter, opts *ServeHeaderOptions) { header.Set("Content-Length", strconv.FormatInt(*opts.ContentLength, 10)) } - if opts.Filename != "" { - disposition := opts.Disposition - if disposition == "" { - disposition = ContentDispositionAttachment + if opts.Filename != "" && opts.ContentDisposition != "" { + // Disable script execution of HTML files, since we serve the file from the same domain as gitea + header.Set("Content-Security-Policy", "sandbox; style-src 'unsafe-inline'; default-src 'none';") + if strings.Contains(contentType, "application/pdf") { + // no sandbox attribute for PDF as it breaks rendering in at least safari. this + // should generally be safe as scripts inside PDF can not escape the PDF document + // see https://bugs.chromium.org/p/chromium/issues/detail?id=413851 for more discussion + // HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context + header.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") } - - header.Set("Content-Disposition", EncodeContentDisposition(disposition, opts.Filename)) + header.Set("Content-Disposition", EncodeContentDisposition(opts.ContentDisposition, path.Base(opts.Filename))) header.Set("Access-Control-Expose-Headers", "Content-Disposition") } @@ -82,13 +80,10 @@ func ServeSetHeaders(w http.ResponseWriter, opts *ServeHeaderOptions) { } } -// ServeData download file from io.Reader -func setServeHeadersByFile(r *http.Request, w http.ResponseWriter, mineBuf []byte, opts *ServeHeaderOptions) { +func setServeHeadersByFile(r *http.Request, w http.ResponseWriter, mineBuf []byte, opts ServeHeaderOptions) { // do not set "Content-Length", because the length could only be set by callers, and it needs to support range requests sniffedType := typesniffer.DetectContentType(mineBuf) - - // the "render" parameter came from year 2016: 638dd24c, it doesn't have clear meaning, so I think it could be removed later - isPlain := sniffedType.IsText() || r.FormValue("render") != "" + isText := sniffedType.IsText() if setting.MimeTypeMap.Enabled { fileExtension := strings.ToLower(filepath.Ext(opts.Filename)) @@ -98,33 +93,22 @@ func setServeHeadersByFile(r *http.Request, w http.ResponseWriter, mineBuf []byt if opts.ContentType == "" { if sniffedType.IsBrowsableBinaryType() { opts.ContentType = sniffedType.GetMimeType() - } else if isPlain { + } else if isText { opts.ContentType = "text/plain" } else { opts.ContentType = typesniffer.MimeTypeApplicationOctetStream } } - if isPlain { - charset, _ := charsetModule.DetectEncoding(mineBuf) - opts.ContentTypeCharset = strings.ToLower(charset) - } - - // serve types that can present a security risk with CSP - w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox") - - if sniffedType.IsPDF() { - // no sandbox attribute for PDF as it breaks rendering in at least safari. this - // should generally be safe as scripts inside PDF can not escape the PDF document - // see https://bugs.chromium.org/p/chromium/issues/detail?id=413851 for more discussion - // HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context - w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") + if isText && !strings.Contains(opts.ContentType, "charset=") { + if charset, _ := charsetModule.DetectEncoding(mineBuf); charset != "" { + opts.ContentType += "; charset=" + strings.ToLower(charset) + } } - // TODO: UNIFY-CONTENT-DISPOSITION-FROM-STORAGE - opts.Disposition = ContentDispositionInline + opts.ContentDisposition = ContentDispositionInline if sniffedType.IsSvgImage() && !setting.UI.SVG.Enabled { - opts.Disposition = ContentDispositionAttachment + opts.ContentDisposition = ContentDispositionAttachment } ServeSetHeaders(w, opts) @@ -132,7 +116,7 @@ func setServeHeadersByFile(r *http.Request, w http.ResponseWriter, mineBuf []byt const mimeDetectionBufferLen = 1024 -func ServeContentByReader(r *http.Request, w http.ResponseWriter, size int64, reader io.Reader, opts *ServeHeaderOptions) { +func ServeContentByReader(r *http.Request, w http.ResponseWriter, size int64, reader io.Reader, opts ServeHeaderOptions) { buf := make([]byte, mimeDetectionBufferLen) n, err := util.ReadAtMost(reader, buf) if err != nil { @@ -205,7 +189,7 @@ func ServeContentByReader(r *http.Request, w http.ResponseWriter, size int64, re _, _ = io.CopyN(w, reader, partialLength) // just like http.ServeContent, not necessary to handle the error } -func ServeContentByReadSeeker(r *http.Request, w http.ResponseWriter, modTime *time.Time, reader io.ReadSeeker, opts *ServeHeaderOptions) { +func ServeContentByReadSeeker(r *http.Request, w http.ResponseWriter, modTime *time.Time, reader io.ReadSeeker, opts ServeHeaderOptions) { buf := make([]byte, mimeDetectionBufferLen) n, err := util.ReadAtMost(reader, buf) if err != nil { diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index 11c0c204237e2..5e33874559e80 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -658,7 +658,7 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) { if setting.Actions.ArtifactStorage.ServeDirect() { // DO NOT USE the http POST method coming from the getSignedArtifactURL endpoint - u, err := actions.GetArtifactV4ServeDirectURL(ctx.Base, artifact, http.MethodGet) + u, err := actions.GetArtifactV4ServeDirectURL(artifact, http.MethodGet) if u != "" && err == nil { respData.SignedUrl = u } diff --git a/routers/common/actions.go b/routers/common/actions.go index 0534956ee6281..f698ba943635d 100644 --- a/routers/common/actions.go +++ b/routers/common/actions.go @@ -61,9 +61,8 @@ func DownloadActionsRunJobLogs(ctx *context.Base, ctxRepo *repo_model.Repository ctx.ServeContent(reader, &context.ServeHeaderOptions{ Filename: fmt.Sprintf("%v-%v-%v.log", workflowName, curJob.Name, task.ID), ContentLength: &task.LogSize, - ContentType: "text/plain", - ContentTypeCharset: "utf-8", - Disposition: httplib.ContentDispositionAttachment, + ContentType: "text/plain; charset=utf-8", + ContentDisposition: httplib.ContentDispositionAttachment, }) return nil } diff --git a/routers/common/serve.go b/routers/common/serve.go index 4bb1a48b0da55..08f555b31cc1d 100644 --- a/routers/common/serve.go +++ b/routers/common/serve.go @@ -35,7 +35,7 @@ func ServeBlob(ctx *context.Base, repo *repo_model.Repository, filePath string, }() _ = repo.LoadOwner(ctx) - httplib.ServeContentByReader(ctx.Req, ctx.Resp, blob.Size(), dataRc, &httplib.ServeHeaderOptions{ + httplib.ServeContentByReader(ctx.Req, ctx.Resp, blob.Size(), dataRc, httplib.ServeHeaderOptions{ Filename: path.Base(filePath), CacheIsPublic: !repo.IsPrivate && repo.Owner != nil && repo.Owner.Visibility == structs.VisibleTypePublic, CacheDuration: setting.StaticCacheTime, @@ -44,9 +44,9 @@ func ServeBlob(ctx *context.Base, repo *repo_model.Repository, filePath string, } func ServeContentByReader(ctx *context.Base, filePath string, size int64, reader io.Reader) { - httplib.ServeContentByReader(ctx.Req, ctx.Resp, size, reader, &httplib.ServeHeaderOptions{Filename: path.Base(filePath)}) + httplib.ServeContentByReader(ctx.Req, ctx.Resp, size, reader, httplib.ServeHeaderOptions{Filename: path.Base(filePath)}) } func ServeContentByReadSeeker(ctx *context.Base, filePath string, modTime *time.Time, reader io.ReadSeeker) { - httplib.ServeContentByReadSeeker(ctx.Req, ctx.Resp, modTime, reader, &httplib.ServeHeaderOptions{Filename: path.Base(filePath)}) + httplib.ServeContentByReadSeeker(ctx.Req, ctx.Resp, modTime, reader, httplib.ServeHeaderOptions{Filename: path.Base(filePath)}) } diff --git a/routers/web/admin/diagnosis.go b/routers/web/admin/diagnosis.go index aee0b25410548..205ab2f8ea151 100644 --- a/routers/web/admin/diagnosis.go +++ b/routers/web/admin/diagnosis.go @@ -18,10 +18,10 @@ import ( func MonitorDiagnosis(ctx *context.Context) { seconds := min(max(ctx.FormInt64("seconds"), 1), 300) - httplib.ServeSetHeaders(ctx.Resp, &httplib.ServeHeaderOptions{ - ContentType: "application/zip", - Disposition: httplib.ContentDispositionAttachment, - Filename: fmt.Sprintf("gitea-diagnosis-%s.zip", time.Now().Format("20060102-150405")), + httplib.ServeSetHeaders(ctx.Resp, httplib.ServeHeaderOptions{ + ContentType: "application/zip", + Filename: fmt.Sprintf("gitea-diagnosis-%s.zip", time.Now().Format("20060102-150405")), + ContentDisposition: httplib.ContentDispositionAttachment, }) zipWriter := zip.NewWriter(ctx.Resp) diff --git a/services/context/base.go b/services/context/base.go index 4baea95ccf9f2..06ccefa3aa54c 100644 --- a/services/context/base.go +++ b/services/context/base.go @@ -173,12 +173,12 @@ func (b *Base) Redirect(location string, status ...int) { type ServeHeaderOptions httplib.ServeHeaderOptions func (b *Base) SetServeHeaders(opt *ServeHeaderOptions) { - httplib.ServeSetHeaders(b.Resp, (*httplib.ServeHeaderOptions)(opt)) + httplib.ServeSetHeaders(b.Resp, *(*httplib.ServeHeaderOptions)(opt)) } // ServeContent serves content to http request func (b *Base) ServeContent(r io.ReadSeeker, opts *ServeHeaderOptions) { - httplib.ServeSetHeaders(b.Resp, (*httplib.ServeHeaderOptions)(opts)) + httplib.ServeSetHeaders(b.Resp, *(*httplib.ServeHeaderOptions)(opts)) http.ServeContent(b.Resp, b.Req, opts.Filename, opts.LastModified, r) } diff --git a/services/repository/archiver/archiver.go b/services/repository/archiver/archiver.go index 1d28e00655c1d..2431ae4b93abf 100644 --- a/services/repository/archiver/archiver.go +++ b/services/repository/archiver/archiver.go @@ -328,7 +328,7 @@ func ServeRepoArchive(ctx *gitea_context.Base, archiveReq *ArchiveRequest) error if setting.Repository.StreamArchives || len(archiveReq.Paths) > 0 { // the header must be set before starting streaming even an error would occur, // because errors may happen in git command and such cases aren't in our control. - httplib.ServeSetHeaders(ctx.Resp, &httplib.ServeHeaderOptions{Filename: downloadName}) + httplib.ServeSetHeaders(ctx.Resp, httplib.ServeHeaderOptions{Filename: downloadName}) if err := archiveReq.Stream(ctx, ctx.Resp); err != nil && !ctx.Written() { if gitcmd.StderrHasPrefix(err, "fatal: pathspec") { return util.NewInvalidArgumentErrorf("path doesn't exist or is invalid") From 071ae49d0b0e0ab751f38b5849859619ef7a1060 Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Wed, 25 Mar 2026 11:13:24 +0100 Subject: [PATCH 60/78] * artifact rest api also use base64.RawURLEncoding to avoid possible bugs with padding * Rename ContentEncodingV4Gzip to ContentEncodingV3Gzip * Not used in v4 * update comments * force ContentEncodingOrType db name to the old one --- models/actions/artifact.go | 10 ++++++---- routers/api/actions/artifacts.go | 2 +- routers/api/actions/artifacts_chunks.go | 2 +- routers/api/v1/repo/action.go | 6 ++++-- routers/web/repo/actions/view.go | 6 ++++-- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/models/actions/artifact.go b/models/actions/artifact.go index 504b2bbf7cb15..318c60a5e76b5 100644 --- a/models/actions/artifact.go +++ b/models/actions/artifact.go @@ -54,7 +54,7 @@ func init() { } const ( - ContentEncodingV4Gzip = "gzip" + ContentEncodingV3Gzip = "gzip" ContentTypeZip = "application/zip" ) @@ -71,12 +71,14 @@ type ActionArtifact struct { FileCompressedSize int64 // The size of the artifact in bytes after gzip compression // The content encoding or content type of the artifact - // * empty or null: legacy (v3) uncompressed content? - // * magic string "gzip" (ContentEncodingV4Gzip): v4 gzip content, the content itself is a gzip file, so serve it directly? + // * empty or null: legacy (v3) uncompressed content + // * magic string "gzip" (ContentEncodingV3Gzip): v3 gzip compressed content + // * requires gzip decoding before storing in a zip for download + // * requires gzip content-encoding header when downloaded single files within a workflow // * mime type like for "Content-Type": // * "application/zip" (ContentTypeZip), seems to be an abuse, fortunately there is no conflict, and it won't cause problems? // * "application/pdf", "text/html", etc.: real content type of the artifact - ContentEncodingOrType string + ContentEncodingOrType string `xorm:"'content_encoding'"` ArtifactPath string `xorm:"index unique(runid_name_path)"` // The path to the artifact when runner uploads it ArtifactName string `xorm:"index unique(runid_name_path)"` // The name of the artifact when runner uploads it diff --git a/routers/api/actions/artifacts.go b/routers/api/actions/artifacts.go index c4d51cd821ea8..a6722616cfed2 100644 --- a/routers/api/actions/artifacts.go +++ b/routers/api/actions/artifacts.go @@ -492,7 +492,7 @@ func (ar artifactRoutes) downloadArtifact(ctx *ArtifactContext) { defer fd.Close() // if artifact is compressed, set content-encoding header to gzip - if artifact.ContentEncodingOrType == actions.ContentEncodingV4Gzip { + if artifact.ContentEncodingOrType == actions.ContentEncodingV3Gzip { 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) diff --git a/routers/api/actions/artifacts_chunks.go b/routers/api/actions/artifacts_chunks.go index 556a70918aa82..8d04c689221a6 100644 --- a/routers/api/actions/artifacts_chunks.go +++ b/routers/api/actions/artifacts_chunks.go @@ -289,7 +289,7 @@ func generateArtifactStoragePath(artifact *actions.ActionArtifact) string { // 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.ContentEncodingOrType == actions.ContentEncodingV4Gzip { + if artifact.ContentEncodingOrType == actions.ContentEncodingV3Gzip { extension = "chunk.gz" } diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 6d9ce36249ae2..dd9cb45d2df66 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -1784,7 +1784,7 @@ func buildDownloadRawEndpoint(repo *repo_model.Repository, artifactID int64) str func buildSigURL(ctx go_context.Context, endPoint string, artifactID int64) string { // endPoint is a path like "api/v1/repos/owner/repo/actions/artifacts/1/zip/raw" expires := time.Now().Add(60 * time.Minute).Unix() - uploadURL := httplib.GuessCurrentAppURL(ctx) + endPoint + "?sig=" + base64.URLEncoding.EncodeToString(buildSignature(endPoint, expires, artifactID)) + "&expires=" + strconv.FormatInt(expires, 10) + uploadURL := httplib.GuessCurrentAppURL(ctx) + endPoint + "?sig=" + base64.RawURLEncoding.EncodeToString(buildSignature(endPoint, expires, artifactID)) + "&expires=" + strconv.FormatInt(expires, 10) return uploadURL } @@ -1831,6 +1831,8 @@ func DownloadArtifact(ctx *context.APIContext) { } if actions.IsArtifactV4(art) { + // @actions/toolkit asserts that downloaded artifacts of a different runid return 302 + // https://github.com/actions/toolkit/blob/44d43b5490b02998bd09b0c4ff369a4cc67876c2/packages/artifact/src/internal/download/download-artifact.ts#L203-L210 ok, err := actions.DownloadArtifactV4ServeDirectOnly(ctx.Base, art) if ok { return @@ -1867,7 +1869,7 @@ func DownloadArtifactRaw(ctx *context.APIContext) { sigStr := ctx.Req.URL.Query().Get("sig") expiresStr := ctx.Req.URL.Query().Get("expires") - sigBytes, _ := base64.URLEncoding.DecodeString(sigStr) + sigBytes, _ := base64.RawURLEncoding.DecodeString(sigStr) expires, _ := strconv.ParseInt(expiresStr, 10, 64) expectedSig := buildSignature(buildDownloadRawEndpoint(repo, art.ID), expires, art.ID) diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 9613746884d5e..db9abf11f7d6a 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -717,7 +717,9 @@ func ArtifactsDownloadView(ctx *context_module.Context) { } } - // FIXME: what if IsArtifactV4 but have multiple artifacts? + // A v4 Artifact may only contain a single file + // Multiple files are uploaded as a single file archive + // All other cases fall back to the legacy v1–v3 zip handling below if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) { err := actions.DownloadArtifactV4(ctx.Base, artifacts[0]) if err != nil { @@ -742,7 +744,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) { defer f.Close() var r io.ReadCloser = f - if art.ContentEncodingOrType == actions_model.ContentEncodingV4Gzip { + if art.ContentEncodingOrType == actions_model.ContentEncodingV3Gzip { r, err = gzip.NewReader(f) if err != nil { return fmt.Errorf("gzip.NewReader: %w", err) From 068a3249f9a9e4bc4b3f72c2baa7aff5b4b3ebe9 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 25 Mar 2026 18:15:07 +0800 Subject: [PATCH 61/78] add comment # Conflicts: # routers/web/repo/actions/view.go --- routers/api/v1/repo/action.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index dd9cb45d2df66..a0c89f9945651 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -1842,6 +1842,8 @@ func DownloadArtifact(ctx *context.APIContext) { return } + // @actions/toolkit asserts a 302 for the artifact download, so we have to build a signed URL and redirect to it + // TODO: a perma link to the code for reference redirectURL := buildSigURL(ctx, buildDownloadRawEndpoint(ctx.Repo.Repository, art.ID), art.ID) ctx.Redirect(redirectURL, http.StatusFound) return From c5447344d2144ecbea4e2b6b43f3175f374a10c5 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 25 Mar 2026 18:18:55 +0800 Subject: [PATCH 62/78] fix comments and tests --- modules/httplib/serve_test.go | 4 ++-- routers/web/repo/actions/view.go | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/modules/httplib/serve_test.go b/modules/httplib/serve_test.go index 78b88c9b5f161..a3ebd3c93d662 100644 --- a/modules/httplib/serve_test.go +++ b/modules/httplib/serve_test.go @@ -27,7 +27,7 @@ func TestServeContentByReader(t *testing.T) { } reader := strings.NewReader(data) w := httptest.NewRecorder() - ServeContentByReader(r, w, int64(len(data)), reader, &ServeHeaderOptions{}) + ServeContentByReader(r, w, int64(len(data)), reader, ServeHeaderOptions{}) assert.Equal(t, expectedStatusCode, w.Code) if expectedStatusCode == http.StatusPartialContent || expectedStatusCode == http.StatusOK { assert.Equal(t, strconv.Itoa(len(expectedContent)), w.Header().Get("Content-Length")) @@ -76,7 +76,7 @@ func TestServeContentByReadSeeker(t *testing.T) { defer seekReader.Close() w := httptest.NewRecorder() - ServeContentByReadSeeker(r, w, nil, seekReader, &ServeHeaderOptions{}) + ServeContentByReadSeeker(r, w, nil, seekReader, ServeHeaderOptions{}) assert.Equal(t, expectedStatusCode, w.Code) if expectedStatusCode == http.StatusPartialContent || expectedStatusCode == http.StatusOK { assert.Equal(t, strconv.Itoa(len(expectedContent)), w.Header().Get("Content-Length")) diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index db9abf11f7d6a..0aa1494394dda 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -729,10 +729,9 @@ func ArtifactsDownloadView(ctx *context_module.Context) { return } - ctx.Resp.Header().Set("Content-Disposition", httplib.EncodeContentDisposition(httplib.ContentDispositionAttachment, artifactName+".zip")) - // Artifacts using the v1-v3 backend are stored as multiple individual files per artifact on the backend // Those need to be zipped for download + ctx.Resp.Header().Set("Content-Disposition", httplib.EncodeContentDisposition(httplib.ContentDispositionAttachment, artifactName+".zip")) zipWriter := zip.NewWriter(ctx.Resp) defer zipWriter.Close() From 57a7679d2d5693608e606c2deca5d4bec25c5edb Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 25 Mar 2026 18:23:27 +0800 Subject: [PATCH 63/78] fix db column name --- models/actions/artifact.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models/actions/artifact.go b/models/actions/artifact.go index 318c60a5e76b5..e0e57f8bd4cd2 100644 --- a/models/actions/artifact.go +++ b/models/actions/artifact.go @@ -78,7 +78,7 @@ type ActionArtifact struct { // * mime type like for "Content-Type": // * "application/zip" (ContentTypeZip), seems to be an abuse, fortunately there is no conflict, and it won't cause problems? // * "application/pdf", "text/html", etc.: real content type of the artifact - ContentEncodingOrType string `xorm:"'content_encoding'"` + ContentEncodingOrType string `xorm:"content_encoding"` ArtifactPath string `xorm:"index unique(runid_name_path)"` // The path to the artifact when runner uploads it ArtifactName string `xorm:"index unique(runid_name_path)"` // The name of the artifact when runner uploads it From efd98a2693ec8d91e626d45e44f719e01e3ca30c Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 25 Mar 2026 18:33:11 +0800 Subject: [PATCH 64/78] clean up --- modules/actions/artifacts.go | 34 +++++++++++++----------------- modules/storage/storage.go | 23 ++++++++++---------- routers/api/actions/artifactsv4.go | 2 +- routers/api/v1/repo/action.go | 7 +----- 4 files changed, 28 insertions(+), 38 deletions(-) diff --git a/modules/actions/artifacts.go b/modules/actions/artifacts.go index 1f940a4cee392..d42629e5a8d0f 100644 --- a/modules/actions/artifacts.go +++ b/modules/actions/artifacts.go @@ -25,27 +25,24 @@ func IsArtifactV4(art *actions_model.ActionArtifact) bool { func GetArtifactV4ServeDirectURL(art *actions_model.ActionArtifact, method string) (string, error) { contentType := art.ContentEncodingOrType - contentDisposition := httplib.EncodeContentDisposition(httplib.ContentDispositionInline, path.Base(art.ArtifactPath)) - u, err := storage.ActionsArtifacts.ServeDirectURL(art.StoragePath, art.ArtifactPath, method, &storage.ServeDirectOptions{ - ContentType: contentType, - ContentDisposition: contentDisposition, - }) + u, err := storage.ActionsArtifacts.ServeDirectURL(art.StoragePath, art.ArtifactPath, method, &storage.ServeDirectOptions{ContentType: contentType}) if err != nil { - log.Error("GetArtifactV4ServeDirectURL failed with error: %v", err) - return "", nil + return "", err } return u.String(), nil } -func DownloadArtifactV4ServeDirectOnly(ctx *context.Base, art *actions_model.ActionArtifact) (bool, error) { - if setting.Actions.ArtifactStorage.ServeDirect() { - u, err := GetArtifactV4ServeDirectURL(art, ctx.Req.Method) - if u != "" && err == nil { - ctx.Redirect(u, http.StatusFound) - return true, nil - } +func DownloadArtifactV4ServeDirectOnly(ctx *context.Base, art *actions_model.ActionArtifact) bool { + if !setting.Actions.ArtifactStorage.ServeDirect() { + return false } - return false, nil + u, err := GetArtifactV4ServeDirectURL(art, ctx.Req.Method) + if err != nil { + log.Error("GetArtifactV4ServeDirectURL: %v", err) + return false + } + ctx.Redirect(u, http.StatusFound) + return true } func DownloadArtifactV4Fallback(ctx *context.Base, art *actions_model.ActionArtifact) error { @@ -56,7 +53,7 @@ func DownloadArtifactV4Fallback(ctx *context.Base, art *actions_model.ActionArti defer f.Close() contentType := art.ContentEncodingOrType - contentLength := int64(-1) // do we know the content length (by artifact)? + contentLength := int64(-1) // TODO: do we know the content length (by artifact)? httplib.ServeContentByReader(ctx.Req, ctx.Resp, contentLength, f, httplib.ServeHeaderOptions{ Filename: path.Base(art.ArtifactPath), ContentType: contentType, @@ -66,9 +63,8 @@ func DownloadArtifactV4Fallback(ctx *context.Base, art *actions_model.ActionArti } func DownloadArtifactV4(ctx *context.Base, art *actions_model.ActionArtifact) error { - ok, err := DownloadArtifactV4ServeDirectOnly(ctx, art) - if ok || err != nil { - return err + if DownloadArtifactV4ServeDirectOnly(ctx, art) { + return nil } return DownloadArtifactV4Fallback(ctx, art) } diff --git a/modules/storage/storage.go b/modules/storage/storage.go index 6631045808926..76f03fb15aee6 100644 --- a/modules/storage/storage.go +++ b/modules/storage/storage.go @@ -63,31 +63,30 @@ type Object interface { type ServeDirectOptions struct { // Overrides the automatically detected MIME type. ContentType string - // Overrides the default Content-Disposition header, which is `inline; filename="name"`. - ContentDisposition string } // Safe defaults are applied only when not explicitly overridden by the caller. -func prepareServeDirectOptions(optsOptional *ServeDirectOptions, name string) (ret ServeDirectOptions) { +func prepareServeDirectOptions(optsOptional *ServeDirectOptions, name string) (ret struct { + ContentType string + ContentDisposition string +}) { // Here we might not know the real filename, and it's quite inefficient to detect the MIME type by pre-fetching the object head. // So we just do a quick detection by extension name, at least it works for the "View Raw File" for an LFS file on the Web UI. // TODO: OBJECT-STORAGE-CONTENT-TYPE: need a complete solution and refactor for Azure in the future - if optsOptional != nil { - ret = *optsOptional + if optsOptional == nil { + return ret } - // TODO: UNIFY-CONTENT-DISPOSITION-FROM-STORAGE + ret.ContentType = optsOptional.ContentType if ret.ContentType == "" { ext := path.Ext(name) ret.ContentType = public.DetectWellKnownMimeType(ext) } - if ret.ContentDisposition == "" { - // When using ServeDirect, the URL is from the object storage's web server, - // it is not the same origin as Gitea server, so it should be safe enough to use "inline" to render the content directly. - // If a browser doesn't support the content type to be displayed inline, browser will download with the filename. - ret.ContentDisposition = httplib.EncodeContentDisposition(httplib.ContentDispositionInline, name) - } + // When using ServeDirect, the URL is from the object storage's web server, + // it is not the same origin as Gitea server, so it should be safe enough to use "inline" to render the content directly. + // If a browser doesn't support the content type to be displayed inline, browser will download with the filename. + ret.ContentDisposition = httplib.EncodeContentDisposition(httplib.ContentDispositionInline, name) return ret } diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index 5e33874559e80..848557102923a 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -659,7 +659,7 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) { if setting.Actions.ArtifactStorage.ServeDirect() { // DO NOT USE the http POST method coming from the getSignedArtifactURL endpoint u, err := actions.GetArtifactV4ServeDirectURL(artifact, http.MethodGet) - if u != "" && err == nil { + if err == nil { respData.SignedUrl = u } } diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index a0c89f9945651..ec2d3eb3d9f0f 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -1833,12 +1833,7 @@ func DownloadArtifact(ctx *context.APIContext) { if actions.IsArtifactV4(art) { // @actions/toolkit asserts that downloaded artifacts of a different runid return 302 // https://github.com/actions/toolkit/blob/44d43b5490b02998bd09b0c4ff369a4cc67876c2/packages/artifact/src/internal/download/download-artifact.ts#L203-L210 - ok, err := actions.DownloadArtifactV4ServeDirectOnly(ctx.Base, art) - if ok { - return - } - if err != nil { - ctx.APIErrorInternal(err) + if actions.DownloadArtifactV4ServeDirectOnly(ctx.Base, art) { return } From 7364517c3de4705f756df4372b46fdf2ee827ef2 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 25 Mar 2026 18:49:29 +0800 Subject: [PATCH 65/78] clean up --- models/actions/artifact.go | 2 +- modules/actions/artifacts.go | 8 ++++---- modules/httplib/serve.go | 28 ++++++++++++++-------------- routers/api/actions/artifactsv4.go | 4 ++-- routers/api/v1/repo/action.go | 2 +- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/models/actions/artifact.go b/models/actions/artifact.go index e0e57f8bd4cd2..d61afb2aed47b 100644 --- a/models/actions/artifact.go +++ b/models/actions/artifact.go @@ -75,7 +75,7 @@ type ActionArtifact struct { // * magic string "gzip" (ContentEncodingV3Gzip): v3 gzip compressed content // * requires gzip decoding before storing in a zip for download // * requires gzip content-encoding header when downloaded single files within a workflow - // * mime type like for "Content-Type": + // * mime type for "Content-Type": // * "application/zip" (ContentTypeZip), seems to be an abuse, fortunately there is no conflict, and it won't cause problems? // * "application/pdf", "text/html", etc.: real content type of the artifact ContentEncodingOrType string `xorm:"content_encoding"` diff --git a/modules/actions/artifacts.go b/modules/actions/artifacts.go index d42629e5a8d0f..ce0e24d63707c 100644 --- a/modules/actions/artifacts.go +++ b/modules/actions/artifacts.go @@ -32,7 +32,7 @@ func GetArtifactV4ServeDirectURL(art *actions_model.ActionArtifact, method strin return u.String(), nil } -func DownloadArtifactV4ServeDirectOnly(ctx *context.Base, art *actions_model.ActionArtifact) bool { +func DownloadArtifactV4ServeDirect(ctx *context.Base, art *actions_model.ActionArtifact) bool { if !setting.Actions.ArtifactStorage.ServeDirect() { return false } @@ -45,7 +45,7 @@ func DownloadArtifactV4ServeDirectOnly(ctx *context.Base, art *actions_model.Act return true } -func DownloadArtifactV4Fallback(ctx *context.Base, art *actions_model.ActionArtifact) error { +func DownloadArtifactV4ReadStorage(ctx *context.Base, art *actions_model.ActionArtifact) error { f, err := storage.ActionsArtifacts.Open(art.StoragePath) if err != nil { return err @@ -63,8 +63,8 @@ func DownloadArtifactV4Fallback(ctx *context.Base, art *actions_model.ActionArti } func DownloadArtifactV4(ctx *context.Base, art *actions_model.ActionArtifact) error { - if DownloadArtifactV4ServeDirectOnly(ctx, art) { + if DownloadArtifactV4ServeDirect(ctx, art) { return nil } - return DownloadArtifactV4Fallback(ctx, art) + return DownloadArtifactV4ReadStorage(ctx, art) } diff --git a/modules/httplib/serve.go b/modules/httplib/serve.go index ee882cc665fdf..f299831f72935 100644 --- a/modules/httplib/serve.go +++ b/modules/httplib/serve.go @@ -10,7 +10,6 @@ import ( "io" "net/http" "path" - "path/filepath" "strconv" "strings" "time" @@ -54,16 +53,17 @@ func ServeSetHeaders(w http.ResponseWriter, opts ServeHeaderOptions) { header.Set("Content-Length", strconv.FormatInt(*opts.ContentLength, 10)) } + // Disable script execution of HTML files, since we serve the file from the same domain as gitea + header.Set("Content-Security-Policy", "sandbox; style-src 'unsafe-inline'; default-src 'none';") + if strings.Contains(contentType, "application/pdf") { + // no sandbox attribute for PDF as it breaks rendering in at least safari. this + // should generally be safe as scripts inside PDF can not escape the PDF document + // see https://bugs.chromium.org/p/chromium/issues/detail?id=413851 for more discussion + // HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context + header.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") + } + if opts.Filename != "" && opts.ContentDisposition != "" { - // Disable script execution of HTML files, since we serve the file from the same domain as gitea - header.Set("Content-Security-Policy", "sandbox; style-src 'unsafe-inline'; default-src 'none';") - if strings.Contains(contentType, "application/pdf") { - // no sandbox attribute for PDF as it breaks rendering in at least safari. this - // should generally be safe as scripts inside PDF can not escape the PDF document - // see https://bugs.chromium.org/p/chromium/issues/detail?id=413851 for more discussion - // HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context - header.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") - } header.Set("Content-Disposition", EncodeContentDisposition(opts.ContentDisposition, path.Base(opts.Filename))) header.Set("Access-Control-Expose-Headers", "Content-Disposition") } @@ -80,13 +80,13 @@ func ServeSetHeaders(w http.ResponseWriter, opts ServeHeaderOptions) { } } -func setServeHeadersByFile(r *http.Request, w http.ResponseWriter, mineBuf []byte, opts ServeHeaderOptions) { +func setServeHeadersByFile(w http.ResponseWriter, mineBuf []byte, opts ServeHeaderOptions) { // do not set "Content-Length", because the length could only be set by callers, and it needs to support range requests sniffedType := typesniffer.DetectContentType(mineBuf) isText := sniffedType.IsText() if setting.MimeTypeMap.Enabled { - fileExtension := strings.ToLower(filepath.Ext(opts.Filename)) + fileExtension := strings.ToLower(path.Ext(opts.Filename)) opts.ContentType = setting.MimeTypeMap.Map[fileExtension] } @@ -126,7 +126,7 @@ func ServeContentByReader(r *http.Request, w http.ResponseWriter, size int64, re if n >= 0 { buf = buf[:n] } - setServeHeadersByFile(r, w, buf, opts) + setServeHeadersByFile(w, buf, opts) // reset the reader to the beginning reader = io.MultiReader(bytes.NewReader(buf), reader) @@ -203,7 +203,7 @@ func ServeContentByReadSeeker(r *http.Request, w http.ResponseWriter, modTime *t if n >= 0 { buf = buf[:n] } - setServeHeadersByFile(r, w, buf, opts) + setServeHeadersByFile(w, buf, opts) if modTime == nil { modTime = &time.Time{} } diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index 848557102923a..db865401b08af 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -688,10 +688,10 @@ func (r *artifactV4Routes) downloadArtifact(ctx *ArtifactContext) { return } - err = actions.DownloadArtifactV4Fallback(ctx.Base, artifact) + err = actions.DownloadArtifactV4ReadStorage(ctx.Base, artifact) if err != nil { log.Error("Error serve artifact: %v", err) - ctx.HTTPError(http.StatusInternalServerError, err.Error()) + ctx.HTTPError(http.StatusInternalServerError, "failed to download artifact") } } diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index ec2d3eb3d9f0f..0c48f732abfb4 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -1833,7 +1833,7 @@ func DownloadArtifact(ctx *context.APIContext) { if actions.IsArtifactV4(art) { // @actions/toolkit asserts that downloaded artifacts of a different runid return 302 // https://github.com/actions/toolkit/blob/44d43b5490b02998bd09b0c4ff369a4cc67876c2/packages/artifact/src/internal/download/download-artifact.ts#L203-L210 - if actions.DownloadArtifactV4ServeDirectOnly(ctx.Base, art) { + if actions.DownloadArtifactV4ServeDirect(ctx.Base, art) { return } From 88a7abba597c9f8b84f578339199821026068c09 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 25 Mar 2026 18:57:58 +0800 Subject: [PATCH 66/78] fix tests --- modules/storage/storage_test.go | 32 ++++++++++++++---------------- routers/api/actions/artifactsv4.go | 4 ++-- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/modules/storage/storage_test.go b/modules/storage/storage_test.go index 2332b7df2eaf1..83ee2ef7934f9 100644 --- a/modules/storage/storage_test.go +++ b/modules/storage/storage_test.go @@ -53,7 +53,12 @@ func testStorageIterator(t *testing.T, typStr Type, cfg *setting.Storage) { } } -func testSingleBlobStorageURLContentTypeAndDisposition(t *testing.T, s ObjectStorage, path, name string, expected ServeDirectOptions, reqParams *ServeDirectOptions) { +type expectedServeDirectHeaders struct { + ContentType string + ContentDisposition string +} + +func testSingleBlobStorageURLContentTypeAndDisposition(t *testing.T, s ObjectStorage, path, name string, expected expectedServeDirectHeaders, reqParams *ServeDirectOptions) { u, err := s.ServeDirectURL(path, name, http.MethodGet, reqParams) require.NoError(t, err) resp, err := http.Get(u.String()) @@ -71,36 +76,29 @@ func testBlobStorageURLContentTypeAndDisposition(t *testing.T, typStr Type, cfg s, err := NewStorage(typStr, cfg) assert.NoError(t, err) - data := "Q2xTckt6Y1hDOWh0" // arbitrary test content; specific value is irrelevant to this test - testfilename := "test.txt" // arbitrary file name; specific value is irrelevant to this test - _, err = s.Save(testfilename, strings.NewReader(data), int64(len(data))) + testFilename := "test.txt" + _, err = s.Save(testFilename, strings.NewReader("dummy-content"), -1) assert.NoError(t, err) - testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.txt", ServeDirectOptions{ + testSingleBlobStorageURLContentTypeAndDisposition(t, s, testFilename, "test.txt", expectedServeDirectHeaders{ ContentType: "text/plain; charset=utf-8", ContentDisposition: `inline; filename=test.txt`, }, nil) - testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.pdf", ServeDirectOptions{ + testSingleBlobStorageURLContentTypeAndDisposition(t, s, testFilename, "test.pdf", expectedServeDirectHeaders{ ContentType: "application/pdf", ContentDisposition: `inline; filename=test.pdf`, }, nil) - testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.wasm", ServeDirectOptions{ + testSingleBlobStorageURLContentTypeAndDisposition(t, s, testFilename, "test.wasm", expectedServeDirectHeaders{ ContentDisposition: `inline; filename=test.wasm`, }, nil) - testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.wasm", ServeDirectOptions{ + testSingleBlobStorageURLContentTypeAndDisposition(t, s, testFilename, "test.wasm", expectedServeDirectHeaders{ + ContentType: "application/wasm", ContentDisposition: `inline; filename=test.wasm`, - }, &ServeDirectOptions{}) - - testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.txt", ServeDirectOptions{ - ContentType: "application/octet-stream", - ContentDisposition: `inline; filename=test.xml`, }, &ServeDirectOptions{ - ContentType: "application/octet-stream", - ContentDisposition: `inline; filename=test.xml`, + ContentType: "application/wasm", }) - - assert.NoError(t, s.Delete(testfilename)) + assert.NoError(t, s.Delete(testFilename)) } diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index db865401b08af..7a2087f3eb678 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -525,9 +525,9 @@ func (r *artifactV4Routes) finalizeDefaultArtifact(ctx *ArtifactContext, req *Fi } func (r *artifactV4Routes) finalizeAzureServeDirect(ctx *ArtifactContext, req *FinalizeArtifactRequest, artifact *actions_model.ActionArtifact) { - checksumValue, found := strings.CutPrefix(req.GetHash().GetValue(), "sha256:") + checksumValue, hasSha256Checksum := strings.CutPrefix(req.GetHash().GetValue(), "sha256:") var actualLength int64 - if found { + if hasSha256Checksum { hashSha256 := sha256.New() obj, err := storage.ActionsArtifacts.Open(artifact.StoragePath) if err != nil { From 6c40a5600fbe8d168f8ce8e53639b160af7d29a5 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 25 Mar 2026 18:58:34 +0800 Subject: [PATCH 67/78] fix fmt --- modules/storage/storage.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/storage/storage.go b/modules/storage/storage.go index 76f03fb15aee6..767c27405cb4c 100644 --- a/modules/storage/storage.go +++ b/modules/storage/storage.go @@ -69,7 +69,8 @@ type ServeDirectOptions struct { func prepareServeDirectOptions(optsOptional *ServeDirectOptions, name string) (ret struct { ContentType string ContentDisposition string -}) { +}, +) { // Here we might not know the real filename, and it's quite inefficient to detect the MIME type by pre-fetching the object head. // So we just do a quick detection by extension name, at least it works for the "View Raw File" for an LFS file on the Web UI. // TODO: OBJECT-STORAGE-CONTENT-TYPE: need a complete solution and refactor for Azure in the future From 4fc90b0732eba8ad0c2f4e1cb49b33d403bb1979 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 25 Mar 2026 19:15:57 +0800 Subject: [PATCH 68/78] fix test --- modules/storage/storage.go | 5 ++--- routers/api/actions/artifactsv4.go | 2 +- routers/web/repo/actions/view.go | 5 ++++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/modules/storage/storage.go b/modules/storage/storage.go index 767c27405cb4c..ca5091e7521d9 100644 --- a/modules/storage/storage.go +++ b/modules/storage/storage.go @@ -75,11 +75,10 @@ func prepareServeDirectOptions(optsOptional *ServeDirectOptions, name string) (r // So we just do a quick detection by extension name, at least it works for the "View Raw File" for an LFS file on the Web UI. // TODO: OBJECT-STORAGE-CONTENT-TYPE: need a complete solution and refactor for Azure in the future - if optsOptional == nil { - return ret + if optsOptional != nil { + ret.ContentType = optsOptional.ContentType } - ret.ContentType = optsOptional.ContentType if ret.ContentType == "" { ext := path.Ext(name) ret.ContentType = public.DetectWellKnownMimeType(ext) diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index 7a2087f3eb678..e86645cb0cf1b 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -268,7 +268,7 @@ func (r *artifactV4Routes) verifySignature(ctx *ArtifactContext, endp string) (* func (r *artifactV4Routes) getArtifactByName(ctx *ArtifactContext, runID int64, name string) (*actions_model.ActionArtifact, error) { var art actions_model.ActionArtifact - has, err := db.GetEngine(ctx).Where(builder.Eq{"run_id": runID, "artifact_name": name}, builder.Like{"content_encoding", "/"}).Get(&art) + has, err := db.GetEngine(ctx).Where(builder.Eq{"run_id": runID, "artifact_name": name}, builder.Like{"content_encoding", "%/%"}).Get(&art) if err != nil { return nil, err } else if !has { diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 0aa1494394dda..37f845b736264 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -756,7 +756,10 @@ func ArtifactsDownloadView(ctx *context_module.Context) { return fmt.Errorf("zipWriter.Create: %w", err) } _, err = io.Copy(w, r) - return fmt.Errorf("io.Copy: %w", err) + if err != nil { + return fmt.Errorf("io.Copy: %w", err) + } + return nil } for _, art := range artifacts { From 50737ec1330849ea8dd6c22f42cc2734c62c5250 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 25 Mar 2026 19:38:23 +0800 Subject: [PATCH 69/78] fix test --- modules/httplib/content_disposition.go | 2 -- modules/httplib/serve.go | 4 ++-- modules/storage/storage.go | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/modules/httplib/content_disposition.go b/modules/httplib/content_disposition.go index ff65104077c18..ac44c1a3d7229 100644 --- a/modules/httplib/content_disposition.go +++ b/modules/httplib/content_disposition.go @@ -5,7 +5,6 @@ package httplib import ( "mime" - "path" "strings" "code.gitea.io/gitea/modules/setting" @@ -38,7 +37,6 @@ func getSafeName(s string) (_ string, needsEncoding bool) { // EncodeContentDisposition encodes a correct Content-Disposition Header func EncodeContentDisposition(t ContentDispositionType, filename string) string { - filename = path.Base(filename) safeFilename, needsEncoding := getSafeName(filename) result := mime.FormatMediaType(string(t), map[string]string{"filename": safeFilename}) // No need for the utf8 encoding diff --git a/modules/httplib/serve.go b/modules/httplib/serve.go index f299831f72935..27cc89c27d9f8 100644 --- a/modules/httplib/serve.go +++ b/modules/httplib/serve.go @@ -53,8 +53,8 @@ func ServeSetHeaders(w http.ResponseWriter, opts ServeHeaderOptions) { header.Set("Content-Length", strconv.FormatInt(*opts.ContentLength, 10)) } - // Disable script execution of HTML files, since we serve the file from the same domain as gitea - header.Set("Content-Security-Policy", "sandbox; style-src 'unsafe-inline'; default-src 'none';") + // Disable script execution of HTML/SVG files, since we serve the file from the same origin as Gitea server + header.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox") if strings.Contains(contentType, "application/pdf") { // no sandbox attribute for PDF as it breaks rendering in at least safari. this // should generally be safe as scripts inside PDF can not escape the PDF document diff --git a/modules/storage/storage.go b/modules/storage/storage.go index ca5091e7521d9..d994e14606e1f 100644 --- a/modules/storage/storage.go +++ b/modules/storage/storage.go @@ -78,7 +78,7 @@ func prepareServeDirectOptions(optsOptional *ServeDirectOptions, name string) (r if optsOptional != nil { ret.ContentType = optsOptional.ContentType } - + name = path.Base(name) if ret.ContentType == "" { ext := path.Ext(name) ret.ContentType = public.DetectWellKnownMimeType(ext) From dc0f605442bf8f41c0c0c5c313ba2053c64f5e74 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 25 Mar 2026 19:45:45 +0800 Subject: [PATCH 70/78] simplify code --- modules/httplib/content_disposition.go | 12 ++++++++++-- modules/httplib/content_disposition_test.go | 2 +- modules/httplib/serve.go | 2 +- modules/storage/storage.go | 2 +- routers/web/repo/actions/view.go | 2 +- services/lfs/server.go | 2 +- 6 files changed, 15 insertions(+), 7 deletions(-) diff --git a/modules/httplib/content_disposition.go b/modules/httplib/content_disposition.go index ac44c1a3d7229..da23dae2210dc 100644 --- a/modules/httplib/content_disposition.go +++ b/modules/httplib/content_disposition.go @@ -35,8 +35,16 @@ func getSafeName(s string) (_ string, needsEncoding bool) { return out.String(), needsEncoding } -// EncodeContentDisposition encodes a correct Content-Disposition Header -func EncodeContentDisposition(t ContentDispositionType, filename string) string { +func EncodeContentDispositionAttachment(filename string) string { + return encodeContentDisposition(ContentDispositionAttachment, filename) +} + +func EncodeContentDispositionInline(filename string) string { + return encodeContentDisposition(ContentDispositionInline, filename) +} + +// encodeContentDisposition encodes a correct Content-Disposition Header +func encodeContentDisposition(t ContentDispositionType, filename string) string { safeFilename, needsEncoding := getSafeName(filename) result := mime.FormatMediaType(string(t), map[string]string{"filename": safeFilename}) // No need for the utf8 encoding diff --git a/modules/httplib/content_disposition_test.go b/modules/httplib/content_disposition_test.go index d16c37437b295..bf5040e107508 100644 --- a/modules/httplib/content_disposition_test.go +++ b/modules/httplib/content_disposition_test.go @@ -53,7 +53,7 @@ func TestContentDisposition(t *testing.T) { for _, entry := range table { t.Run(string(entry.disposition)+"_"+entry.filename, func(t *testing.T) { - encoded := EncodeContentDisposition(entry.disposition, entry.filename) + encoded := encodeContentDisposition(entry.disposition, entry.filename) assert.Equal(t, entry.header, encoded) disposition, params, err := mime.ParseMediaType(encoded) require.NoError(t, err) diff --git a/modules/httplib/serve.go b/modules/httplib/serve.go index 27cc89c27d9f8..07d559b671d66 100644 --- a/modules/httplib/serve.go +++ b/modules/httplib/serve.go @@ -64,7 +64,7 @@ func ServeSetHeaders(w http.ResponseWriter, opts ServeHeaderOptions) { } if opts.Filename != "" && opts.ContentDisposition != "" { - header.Set("Content-Disposition", EncodeContentDisposition(opts.ContentDisposition, path.Base(opts.Filename))) + header.Set("Content-Disposition", encodeContentDisposition(opts.ContentDisposition, path.Base(opts.Filename))) header.Set("Access-Control-Expose-Headers", "Content-Disposition") } diff --git a/modules/storage/storage.go b/modules/storage/storage.go index d994e14606e1f..e19c421ba826b 100644 --- a/modules/storage/storage.go +++ b/modules/storage/storage.go @@ -86,7 +86,7 @@ func prepareServeDirectOptions(optsOptional *ServeDirectOptions, name string) (r // When using ServeDirect, the URL is from the object storage's web server, // it is not the same origin as Gitea server, so it should be safe enough to use "inline" to render the content directly. // If a browser doesn't support the content type to be displayed inline, browser will download with the filename. - ret.ContentDisposition = httplib.EncodeContentDisposition(httplib.ContentDispositionInline, name) + ret.ContentDisposition = httplib.EncodeContentDispositionInline(name) return ret } diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 37f845b736264..90810a6d2513a 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -731,7 +731,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) { // Artifacts using the v1-v3 backend are stored as multiple individual files per artifact on the backend // Those need to be zipped for download - ctx.Resp.Header().Set("Content-Disposition", httplib.EncodeContentDisposition(httplib.ContentDispositionAttachment, artifactName+".zip")) + ctx.Resp.Header().Set("Content-Disposition", httplib.EncodeContentDispositionAttachment(artifactName+".zip")) zipWriter := zip.NewWriter(ctx.Resp) defer zipWriter.Close() diff --git a/services/lfs/server.go b/services/lfs/server.go index f57b92adabc6b..d0fd841041fd0 100644 --- a/services/lfs/server.go +++ b/services/lfs/server.go @@ -172,7 +172,7 @@ func DownloadHandler(ctx *context.Context) { if len(filename) > 0 { decodedFilename, err := base64.RawURLEncoding.DecodeString(filename) if err == nil { - ctx.Resp.Header().Set("Content-Disposition", httplib.EncodeContentDisposition(httplib.ContentDispositionAttachment, string(decodedFilename))) + ctx.Resp.Header().Set("Content-Disposition", httplib.EncodeContentDispositionAttachment(string(decodedFilename))) ctx.Resp.Header().Set("Access-Control-Expose-Headers", "Content-Disposition") } } From 51371c31b50b1f1f51bffda9cbd5eaae6fc1fc7e Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 25 Mar 2026 20:31:17 +0800 Subject: [PATCH 71/78] fix content type detection --- modules/httplib/serve.go | 26 +++++++++++--------------- modules/typesniffer/typesniffer.go | 4 ++++ 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/modules/httplib/serve.go b/modules/httplib/serve.go index 07d559b671d66..4fa77d537a5fa 100644 --- a/modules/httplib/serve.go +++ b/modules/httplib/serve.go @@ -26,7 +26,7 @@ import ( type ServeHeaderOptions struct { ContentType string // defaults to "application/octet-stream" - ContentLength *int64 + ContentLength *int64 // the length can only be set by callers, it is required for range requests Filename string ContentDisposition ContentDispositionType @@ -80,32 +80,28 @@ func ServeSetHeaders(w http.ResponseWriter, opts ServeHeaderOptions) { } } -func setServeHeadersByFile(w http.ResponseWriter, mineBuf []byte, opts ServeHeaderOptions) { - // do not set "Content-Length", because the length could only be set by callers, and it needs to support range requests - sniffedType := typesniffer.DetectContentType(mineBuf) - isText := sniffedType.IsText() - +func serveSetHeadersByFileContent(w http.ResponseWriter, contentPrefetchBuf []byte, opts ServeHeaderOptions) { if setting.MimeTypeMap.Enabled { fileExtension := strings.ToLower(path.Ext(opts.Filename)) opts.ContentType = setting.MimeTypeMap.Map[fileExtension] } if opts.ContentType == "" { + sniffedType := typesniffer.DetectContentType(contentPrefetchBuf) if sniffedType.IsBrowsableBinaryType() { opts.ContentType = sniffedType.GetMimeType() - } else if isText { + } else if sniffedType.IsText() { + // intentionally do not render user's HTML content as a page, for safety, and avoid content spamming & abusing opts.ContentType = "text/plain" + if charset, _ := charsetModule.DetectEncoding(contentPrefetchBuf); charset != "" { + opts.ContentType += "; charset=" + strings.ToLower(charset) + } } else { opts.ContentType = typesniffer.MimeTypeApplicationOctetStream } } - if isText && !strings.Contains(opts.ContentType, "charset=") { - if charset, _ := charsetModule.DetectEncoding(mineBuf); charset != "" { - opts.ContentType += "; charset=" + strings.ToLower(charset) - } - } - + sniffedType := typesniffer.FromContentType(opts.ContentType) opts.ContentDisposition = ContentDispositionInline if sniffedType.IsSvgImage() && !setting.UI.SVG.Enabled { opts.ContentDisposition = ContentDispositionAttachment @@ -126,7 +122,7 @@ func ServeContentByReader(r *http.Request, w http.ResponseWriter, size int64, re if n >= 0 { buf = buf[:n] } - setServeHeadersByFile(w, buf, opts) + serveSetHeadersByFileContent(w, buf, opts) // reset the reader to the beginning reader = io.MultiReader(bytes.NewReader(buf), reader) @@ -203,7 +199,7 @@ func ServeContentByReadSeeker(r *http.Request, w http.ResponseWriter, modTime *t if n >= 0 { buf = buf[:n] } - setServeHeadersByFile(w, buf, opts) + serveSetHeadersByFileContent(w, buf, opts) if modTime == nil { modTime = &time.Time{} } diff --git a/modules/typesniffer/typesniffer.go b/modules/typesniffer/typesniffer.go index 0c4867d8f018a..90423d48ce364 100644 --- a/modules/typesniffer/typesniffer.go +++ b/modules/typesniffer/typesniffer.go @@ -183,3 +183,7 @@ func DetectContentType(data []byte) SniffedType { } return SniffedType{ct} } + +func FromContentType(contentType string) SniffedType { + return SniffedType{contentType} +} From 7b9922a7544f6e6f2b6503152c4f52b761951611 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 25 Mar 2026 20:50:25 +0800 Subject: [PATCH 72/78] fix content serving --- modules/actions/artifacts.go | 8 ++++++-- routers/api/v1/repo/file.go | 4 ++-- routers/common/serve.go | 17 +++++------------ 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/modules/actions/artifacts.go b/modules/actions/artifacts.go index ce0e24d63707c..deccc7bec506d 100644 --- a/modules/actions/artifacts.go +++ b/modules/actions/artifacts.go @@ -52,9 +52,13 @@ func DownloadArtifactV4ReadStorage(ctx *context.Base, art *actions_model.ActionA } defer f.Close() + stat, err := f.Stat() + if err != nil { + return err + } + contentType := art.ContentEncodingOrType - contentLength := int64(-1) // TODO: do we know the content length (by artifact)? - httplib.ServeContentByReader(ctx.Req, ctx.Resp, contentLength, f, httplib.ServeHeaderOptions{ + httplib.ServeContentByReadSeeker(ctx.Req, ctx.Resp, new(stat.ModTime()), f, httplib.ServeHeaderOptions{ Filename: path.Base(art.ArtifactPath), ContentType: contentType, ContentDisposition: httplib.ContentDispositionInline, diff --git a/routers/api/v1/repo/file.go b/routers/api/v1/repo/file.go index d0596d778b7a6..7ba46e02e0418 100644 --- a/routers/api/v1/repo/file.go +++ b/routers/api/v1/repo/file.go @@ -179,7 +179,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) { } // If not cached - serve! - common.ServeContentByReader(ctx.Base, ctx.Repo.TreePath, blob.Size(), bytes.NewReader(buf)) + common.ServeContentByReadSeeker(ctx.Base, ctx.Repo.TreePath, lastModified, bytes.NewReader(buf)) return } @@ -193,7 +193,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) { return } - common.ServeContentByReader(ctx.Base, ctx.Repo.TreePath, blob.Size(), bytes.NewReader(buf)) + common.ServeContentByReadSeeker(ctx.Base, ctx.Repo.TreePath, lastModified, bytes.NewReader(buf)) return } else if err != nil { ctx.APIErrorInternal(err) diff --git a/routers/common/serve.go b/routers/common/serve.go index 08f555b31cc1d..26d3e1fe02d7f 100644 --- a/routers/common/serve.go +++ b/routers/common/serve.go @@ -12,7 +12,6 @@ import ( "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/httpcache" "code.gitea.io/gitea/modules/httplib" - "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/services/context" @@ -28,25 +27,19 @@ func ServeBlob(ctx *context.Base, repo *repo_model.Repository, filePath string, if err != nil { return err } - defer func() { - if err = dataRc.Close(); err != nil { - log.Error("ServeBlob: Close: %v", err) - } - }() + defer dataRc.Close() - _ = repo.LoadOwner(ctx) + if err = repo.LoadOwner(ctx); err != nil { + return err + } httplib.ServeContentByReader(ctx.Req, ctx.Resp, blob.Size(), dataRc, httplib.ServeHeaderOptions{ Filename: path.Base(filePath), - CacheIsPublic: !repo.IsPrivate && repo.Owner != nil && repo.Owner.Visibility == structs.VisibleTypePublic, + CacheIsPublic: !repo.IsPrivate && repo.Owner.Visibility == structs.VisibleTypePublic, CacheDuration: setting.StaticCacheTime, }) return nil } -func ServeContentByReader(ctx *context.Base, filePath string, size int64, reader io.Reader) { - httplib.ServeContentByReader(ctx.Req, ctx.Resp, size, reader, httplib.ServeHeaderOptions{Filename: path.Base(filePath)}) -} - func ServeContentByReadSeeker(ctx *context.Base, filePath string, modTime *time.Time, reader io.ReadSeeker) { httplib.ServeContentByReadSeeker(ctx.Req, ctx.Resp, modTime, reader, httplib.ServeHeaderOptions{Filename: path.Base(filePath)}) } From 6eef0e43a2342f8a6bc163b13277bcfa043357bd Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 25 Mar 2026 21:08:35 +0800 Subject: [PATCH 73/78] fix DownloadArtifactV4ReadStorage --- modules/actions/artifacts.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/modules/actions/artifacts.go b/modules/actions/artifacts.go index deccc7bec506d..22f3ac871502a 100644 --- a/modules/actions/artifacts.go +++ b/modules/actions/artifacts.go @@ -52,13 +52,12 @@ func DownloadArtifactV4ReadStorage(ctx *context.Base, art *actions_model.ActionA } defer f.Close() - stat, err := f.Stat() - if err != nil { - return err - } - contentType := art.ContentEncodingOrType - httplib.ServeContentByReadSeeker(ctx.Req, ctx.Resp, new(stat.ModTime()), f, httplib.ServeHeaderOptions{ + contentLength := int64(-1) // TODO: do we know the content length (by artifact)? + + // here it intentionally doesn't use httplib.ServeContentByReadSeeker, + // because when using object storage, ReadSeeker emits more (2) requests (read prefetch buffer, seek, serve) than by Reader (only one GET request) + httplib.ServeContentByReader(ctx.Req, ctx.Resp, contentLength, f, httplib.ServeHeaderOptions{ Filename: path.Base(art.ArtifactPath), ContentType: contentType, ContentDisposition: httplib.ContentDispositionInline, From 8081b792c79e5029f4d186bed85e20f59d5f8ddc Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 25 Mar 2026 21:55:36 +0800 Subject: [PATCH 74/78] fix incorrect httplib.ServeXxx --- modules/actions/artifacts.go | 14 ++------- modules/httplib/serve.go | 43 +++++++++++++-------------- modules/httplib/serve_test.go | 53 +--------------------------------- modules/lfs/content_store.go | 2 +- routers/api/v1/repo/file.go | 39 +++++-------------------- routers/common/serve.go | 16 +++++----- routers/web/repo/attachment.go | 4 +-- routers/web/repo/download.go | 33 ++++----------------- 8 files changed, 51 insertions(+), 153 deletions(-) diff --git a/modules/actions/artifacts.go b/modules/actions/artifacts.go index 22f3ac871502a..4884eb42e80d5 100644 --- a/modules/actions/artifacts.go +++ b/modules/actions/artifacts.go @@ -5,7 +5,6 @@ package actions import ( "net/http" - "path" "strings" actions_model "code.gitea.io/gitea/models/actions" @@ -51,16 +50,9 @@ func DownloadArtifactV4ReadStorage(ctx *context.Base, art *actions_model.ActionA return err } defer f.Close() - - contentType := art.ContentEncodingOrType - contentLength := int64(-1) // TODO: do we know the content length (by artifact)? - - // here it intentionally doesn't use httplib.ServeContentByReadSeeker, - // because when using object storage, ReadSeeker emits more (2) requests (read prefetch buffer, seek, serve) than by Reader (only one GET request) - httplib.ServeContentByReader(ctx.Req, ctx.Resp, contentLength, f, httplib.ServeHeaderOptions{ - Filename: path.Base(art.ArtifactPath), - ContentType: contentType, - ContentDisposition: httplib.ContentDispositionInline, + httplib.ServeUserContentByFile(ctx.Req, ctx.Resp, f, httplib.ServeHeaderOptions{ + Filename: art.ArtifactPath, + ContentType: art.ContentEncodingOrType, // v4 guarantees that the field is Content-Type }) return nil } diff --git a/modules/httplib/serve.go b/modules/httplib/serve.go index 4fa77d537a5fa..7947d7780f69f 100644 --- a/modules/httplib/serve.go +++ b/modules/httplib/serve.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "io/fs" "net/http" "path" "strconv" @@ -26,7 +27,7 @@ import ( type ServeHeaderOptions struct { ContentType string // defaults to "application/octet-stream" - ContentLength *int64 // the length can only be set by callers, it is required for range requests + ContentLength *int64 Filename string ContentDisposition ContentDispositionType @@ -112,7 +113,10 @@ func serveSetHeadersByFileContent(w http.ResponseWriter, contentPrefetchBuf []by const mimeDetectionBufferLen = 1024 -func ServeContentByReader(r *http.Request, w http.ResponseWriter, size int64, reader io.Reader, opts ServeHeaderOptions) { +func ServeUserContentByReader(r *http.Request, w http.ResponseWriter, size int64, reader io.Reader, opts ServeHeaderOptions) { + if opts.ContentLength != nil { + panic("do not set ContentLength, use size argument instead") + } buf := make([]byte, mimeDetectionBufferLen) n, err := util.ReadAtMost(reader, buf) if err != nil { @@ -176,32 +180,29 @@ func ServeContentByReader(r *http.Request, w http.ResponseWriter, size int64, re partialLength := end - start + 1 w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, size)) w.Header().Set("Content-Length", strconv.FormatInt(partialLength, 10)) - if _, err = io.CopyN(io.Discard, reader, start); err != nil { - http.Error(w, "serve content: unable to skip", http.StatusInternalServerError) - return + + if seeker, ok := reader.(io.Seeker); ok { + if _, err = seeker.Seek(start, io.SeekStart); err != nil { + http.Error(w, "serve content: unable to seek", http.StatusInternalServerError) + return + } + } else { + if _, err = io.CopyN(io.Discard, reader, start); err != nil { + http.Error(w, "serve content: unable to skip", http.StatusInternalServerError) + return + } } w.WriteHeader(http.StatusPartialContent) _, _ = io.CopyN(w, reader, partialLength) // just like http.ServeContent, not necessary to handle the error } -func ServeContentByReadSeeker(r *http.Request, w http.ResponseWriter, modTime *time.Time, reader io.ReadSeeker, opts ServeHeaderOptions) { - buf := make([]byte, mimeDetectionBufferLen) - n, err := util.ReadAtMost(reader, buf) +func ServeUserContentByFile(r *http.Request, w http.ResponseWriter, file fs.File, opts ServeHeaderOptions) { + info, err := file.Stat() if err != nil { - http.Error(w, "serve content: unable to read", http.StatusInternalServerError) + http.Error(w, "unable to serve file, stat error", http.StatusInternalServerError) return } - if _, err = reader.Seek(0, io.SeekStart); err != nil { - http.Error(w, "serve content: unable to seek", http.StatusInternalServerError) - return - } - if n >= 0 { - buf = buf[:n] - } - serveSetHeadersByFileContent(w, buf, opts) - if modTime == nil { - modTime = &time.Time{} - } - http.ServeContent(w, r, opts.Filename, *modTime, reader) + opts.LastModified = info.ModTime() + ServeUserContentByReader(r, w, info.Size(), file, opts) } diff --git a/modules/httplib/serve_test.go b/modules/httplib/serve_test.go index a3ebd3c93d662..3f5ce736b0510 100644 --- a/modules/httplib/serve_test.go +++ b/modules/httplib/serve_test.go @@ -7,13 +7,11 @@ import ( "net/http" "net/http/httptest" "net/url" - "os" "strconv" "strings" "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestServeContentByReader(t *testing.T) { @@ -27,56 +25,7 @@ func TestServeContentByReader(t *testing.T) { } reader := strings.NewReader(data) w := httptest.NewRecorder() - ServeContentByReader(r, w, int64(len(data)), reader, ServeHeaderOptions{}) - assert.Equal(t, expectedStatusCode, w.Code) - if expectedStatusCode == http.StatusPartialContent || expectedStatusCode == http.StatusOK { - assert.Equal(t, strconv.Itoa(len(expectedContent)), w.Header().Get("Content-Length")) - assert.Equal(t, expectedContent, w.Body.String()) - } - } - - t.Run("_range_", func(t *testing.T) { - test(t, http.StatusOK, data) - }) - t.Run("_range_0-", func(t *testing.T) { - test(t, http.StatusPartialContent, data) - }) - t.Run("_range_0-15", func(t *testing.T) { - test(t, http.StatusPartialContent, data) - }) - t.Run("_range_1-", func(t *testing.T) { - test(t, http.StatusPartialContent, data[1:]) - }) - t.Run("_range_1-3", func(t *testing.T) { - test(t, http.StatusPartialContent, data[1:3+1]) - }) - t.Run("_range_16-", func(t *testing.T) { - test(t, http.StatusRequestedRangeNotSatisfiable, "") - }) - t.Run("_range_1-99999", func(t *testing.T) { - test(t, http.StatusPartialContent, data[1:]) - }) -} - -func TestServeContentByReadSeeker(t *testing.T) { - data := "0123456789abcdef" - tmpFile := t.TempDir() + "/test" - err := os.WriteFile(tmpFile, []byte(data), 0o644) - assert.NoError(t, err) - - test := func(t *testing.T, expectedStatusCode int, expectedContent string) { - _, rangeStr, _ := strings.Cut(t.Name(), "_range_") - r := &http.Request{Header: http.Header{}, Form: url.Values{}} - if rangeStr != "" { - r.Header.Set("Range", "bytes="+rangeStr) - } - - seekReader, err := os.OpenFile(tmpFile, os.O_RDONLY, 0o644) - require.NoError(t, err) - defer seekReader.Close() - - w := httptest.NewRecorder() - ServeContentByReadSeeker(r, w, nil, seekReader, ServeHeaderOptions{}) + ServeUserContentByReader(r, w, int64(len(data)), reader, ServeHeaderOptions{}) assert.Equal(t, expectedStatusCode, w.Code) if expectedStatusCode == http.StatusPartialContent || expectedStatusCode == http.StatusOK { assert.Equal(t, strconv.Itoa(len(expectedContent)), w.Header().Get("Content-Length")) diff --git a/modules/lfs/content_store.go b/modules/lfs/content_store.go index 0d9c0c98acca0..be1e6c8e90cec 100644 --- a/modules/lfs/content_store.go +++ b/modules/lfs/content_store.go @@ -104,7 +104,7 @@ func (s *ContentStore) Verify(pointer Pointer) (bool, error) { } // ReadMetaObject will read a git_model.LFSMetaObject and return a reader -func ReadMetaObject(pointer Pointer) (io.ReadSeekCloser, error) { +func ReadMetaObject(pointer Pointer) (storage.Object, error) { contentStore := NewContentStore() return contentStore.Get(pointer) } diff --git a/routers/api/v1/repo/file.go b/routers/api/v1/repo/file.go index 7ba46e02e0418..9949928622c44 100644 --- a/routers/api/v1/repo/file.go +++ b/routers/api/v1/repo/file.go @@ -17,9 +17,9 @@ import ( 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/httplib" "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" @@ -151,35 +151,18 @@ func GetRawFileOrLFS(ctx *context.APIContext) { // 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() + lfsPointerBuf, err := blob.GetBlobBytes(lfs.MetaFileMaxSize) 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)) + pointer, _ := lfs.ReadPointerFromBuffer(lfsPointerBuf) // if it's not a pointer, just serve the data directly if !pointer.IsValid() { - // First handle caching for the blob - if httpcache.HandleGenericETagPrivateCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) { - return - } - - // If not cached - serve! - common.ServeContentByReadSeeker(ctx.Base, ctx.Repo.TreePath, lastModified, bytes.NewReader(buf)) + _, _ = ctx.Resp.Write(lfsPointerBuf) return } @@ -188,12 +171,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) { // 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.HandleGenericETagPrivateCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) { - return - } - - common.ServeContentByReadSeeker(ctx.Base, ctx.Repo.TreePath, lastModified, bytes.NewReader(buf)) + _, _ = ctx.Resp.Write(lfsPointerBuf) return } else if err != nil { ctx.APIErrorInternal(err) @@ -214,14 +192,13 @@ func GetRawFileOrLFS(ctx *context.APIContext) { } } - lfsDataRc, err := lfs.ReadMetaObject(meta.Pointer) + lfsDataFile, err := lfs.ReadMetaObject(meta.Pointer) if err != nil { ctx.APIErrorInternal(err) return } - defer lfsDataRc.Close() - - common.ServeContentByReadSeeker(ctx.Base, ctx.Repo.TreePath, lastModified, lfsDataRc) + defer lfsDataFile.Close() + httplib.ServeUserContentByFile(ctx.Base.Req, ctx.Base.Resp, lfsDataFile, httplib.ServeHeaderOptions{Filename: ctx.Repo.TreePath}) } func getBlobForEntry(ctx *context.APIContext) (blob *git.Blob, entry *git.TreeEntry, lastModified *time.Time) { diff --git a/routers/common/serve.go b/routers/common/serve.go index 26d3e1fe02d7f..9232d90c94fbd 100644 --- a/routers/common/serve.go +++ b/routers/common/serve.go @@ -4,7 +4,6 @@ package common import ( - "io" "path" "time" @@ -23,23 +22,24 @@ func ServeBlob(ctx *context.Base, repo *repo_model.Repository, filePath string, return nil } + if err := repo.LoadOwner(ctx); err != nil { + return err + } + dataRc, err := blob.DataAsync() if err != nil { return err } defer dataRc.Close() - if err = repo.LoadOwner(ctx); err != nil { - return err + if lastModified == nil { + lastModified = new(time.Time) } - httplib.ServeContentByReader(ctx.Req, ctx.Resp, blob.Size(), dataRc, httplib.ServeHeaderOptions{ + httplib.ServeUserContentByReader(ctx.Req, ctx.Resp, blob.Size(), dataRc, httplib.ServeHeaderOptions{ Filename: path.Base(filePath), CacheIsPublic: !repo.IsPrivate && repo.Owner.Visibility == structs.VisibleTypePublic, CacheDuration: setting.StaticCacheTime, + LastModified: *lastModified, }) return nil } - -func ServeContentByReadSeeker(ctx *context.Base, filePath string, modTime *time.Time, reader io.ReadSeeker) { - httplib.ServeContentByReadSeeker(ctx.Req, ctx.Resp, modTime, reader, httplib.ServeHeaderOptions{Filename: path.Base(filePath)}) -} diff --git a/routers/web/repo/attachment.go b/routers/web/repo/attachment.go index 19d533f36243b..9b2c64049bc2f 100644 --- a/routers/web/repo/attachment.go +++ b/routers/web/repo/attachment.go @@ -11,10 +11,10 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/httpcache" + "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/routers/common" "code.gitea.io/gitea/services/attachment" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context/upload" @@ -199,7 +199,7 @@ func ServeAttachment(ctx *context.Context, uuid string) { } defer fr.Close() - common.ServeContentByReadSeeker(ctx.Base, attach.Name, new(attach.CreatedUnix.AsTime()), fr) + httplib.ServeUserContentByFile(ctx.Req, ctx.Resp, fr, httplib.ServeHeaderOptions{Filename: attach.Name}) } // GetAttachment serve attachments diff --git a/routers/web/repo/download.go b/routers/web/repo/download.go index 073d3d7420859..25166ea1d3d74 100644 --- a/routers/web/repo/download.go +++ b/routers/web/repo/download.go @@ -10,8 +10,8 @@ import ( 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/httplib" "code.gitea.io/gitea/modules/lfs" - "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/routers/common" @@ -24,28 +24,15 @@ func ServeBlobOrLFS(ctx *context.Context, blob *git.Blob, lastModified *time.Tim return nil } - dataRc, err := blob.DataAsync() + lfsPointerBuf, err := blob.GetBlobBytes(lfs.MetaFileMaxSize) if err != nil { return err } - closed := false - defer func() { - if closed { - return - } - if err = dataRc.Close(); err != nil { - log.Error("ServeBlobOrLFS: Close: %v", err) - } - }() - pointer, _ := lfs.ReadPointer(dataRc) + pointer, _ := lfs.ReadPointerFromBuffer(lfsPointerBuf) if pointer.IsValid() { meta, _ := git_model.GetLFSMetaObjectByOid(ctx, ctx.Repo.Repository.ID, pointer.Oid) if meta == nil { - if err = dataRc.Close(); err != nil { - log.Error("ServeBlobOrLFS: Close: %v", err) - } - closed = true return common.ServeBlob(ctx.Base, ctx.Repo.Repository, ctx.Repo.TreePath, blob, lastModified) } if httpcache.HandleGenericETagPrivateCache(ctx.Req, ctx.Resp, `"`+pointer.Oid+`"`, meta.UpdatedUnix.AsTimePtr()) { @@ -61,22 +48,14 @@ func ServeBlobOrLFS(ctx *context.Context, blob *git.Blob, lastModified *time.Tim } } - lfsDataRc, err := lfs.ReadMetaObject(meta.Pointer) + lfsDataFile, err := lfs.ReadMetaObject(meta.Pointer) if err != nil { return err } - defer func() { - if err = lfsDataRc.Close(); err != nil { - log.Error("ServeBlobOrLFS: Close: %v", err) - } - }() - common.ServeContentByReadSeeker(ctx.Base, ctx.Repo.TreePath, lastModified, lfsDataRc) + defer lfsDataFile.Close() + httplib.ServeUserContentByFile(ctx.Req, ctx.Resp, lfsDataFile, httplib.ServeHeaderOptions{Filename: ctx.Repo.TreePath}) return nil } - if err = dataRc.Close(); err != nil { - log.Error("ServeBlobOrLFS: Close: %v", err) - } - closed = true return common.ServeBlob(ctx.Base, ctx.Repo.Repository, ctx.Repo.TreePath, blob, lastModified) } From 3c3c78a48d997a79f29e1fad16365f12b80003e7 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 25 Mar 2026 22:01:32 +0800 Subject: [PATCH 75/78] revert tests --- modules/actions/artifacts.go | 5 ++-- modules/httplib/serve_test.go | 53 ++++++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/modules/actions/artifacts.go b/modules/actions/artifacts.go index 4884eb42e80d5..3d5d0ed7d86d6 100644 --- a/modules/actions/artifacts.go +++ b/modules/actions/artifacts.go @@ -51,8 +51,9 @@ func DownloadArtifactV4ReadStorage(ctx *context.Base, art *actions_model.ActionA } defer f.Close() httplib.ServeUserContentByFile(ctx.Req, ctx.Resp, f, httplib.ServeHeaderOptions{ - Filename: art.ArtifactPath, - ContentType: art.ContentEncodingOrType, // v4 guarantees that the field is Content-Type + Filename: art.ArtifactPath, + ContentType: art.ContentEncodingOrType, // v4 guarantees that the field is Content-Type + ContentDisposition: httplib.ContentDispositionInline, // allow to view the contents in browser (TODO: html is still rendered as text due to legacy logic) }) return nil } diff --git a/modules/httplib/serve_test.go b/modules/httplib/serve_test.go index 3f5ce736b0510..38cf4c197f73d 100644 --- a/modules/httplib/serve_test.go +++ b/modules/httplib/serve_test.go @@ -7,14 +7,16 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" "strconv" "strings" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestServeContentByReader(t *testing.T) { +func TestServeUserContentByReader(t *testing.T) { data := "0123456789abcdef" test := func(t *testing.T, expectedStatusCode int, expectedContent string) { @@ -55,3 +57,52 @@ func TestServeContentByReader(t *testing.T) { test(t, http.StatusPartialContent, data[1:]) }) } + +func TestServeUserContentByFile(t *testing.T) { + data := "0123456789abcdef" + tmpFile := t.TempDir() + "/test" + err := os.WriteFile(tmpFile, []byte(data), 0o644) + assert.NoError(t, err) + + test := func(t *testing.T, expectedStatusCode int, expectedContent string) { + _, rangeStr, _ := strings.Cut(t.Name(), "_range_") + r := &http.Request{Header: http.Header{}, Form: url.Values{}} + if rangeStr != "" { + r.Header.Set("Range", "bytes="+rangeStr) + } + + seekReader, err := os.OpenFile(tmpFile, os.O_RDONLY, 0o644) + require.NoError(t, err) + defer seekReader.Close() + + w := httptest.NewRecorder() + ServeUserContentByFile(r, w, seekReader, ServeHeaderOptions{}) + assert.Equal(t, expectedStatusCode, w.Code) + if expectedStatusCode == http.StatusPartialContent || expectedStatusCode == http.StatusOK { + assert.Equal(t, strconv.Itoa(len(expectedContent)), w.Header().Get("Content-Length")) + assert.Equal(t, expectedContent, w.Body.String()) + } + } + + t.Run("_range_", func(t *testing.T) { + test(t, http.StatusOK, data) + }) + t.Run("_range_0-", func(t *testing.T) { + test(t, http.StatusPartialContent, data) + }) + t.Run("_range_0-15", func(t *testing.T) { + test(t, http.StatusPartialContent, data) + }) + t.Run("_range_1-", func(t *testing.T) { + test(t, http.StatusPartialContent, data[1:]) + }) + t.Run("_range_1-3", func(t *testing.T) { + test(t, http.StatusPartialContent, data[1:3+1]) + }) + t.Run("_range_16-", func(t *testing.T) { + test(t, http.StatusRequestedRangeNotSatisfiable, "") + }) + t.Run("_range_1-99999", func(t *testing.T) { + test(t, http.StatusPartialContent, data[1:]) + }) +} From b00f5ec675a218e10bdd108c53288ca40674a450 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 25 Mar 2026 22:06:25 +0800 Subject: [PATCH 76/78] rename internal function --- modules/httplib/serve.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/httplib/serve.go b/modules/httplib/serve.go index 7947d7780f69f..c3300506a1465 100644 --- a/modules/httplib/serve.go +++ b/modules/httplib/serve.go @@ -81,7 +81,7 @@ func ServeSetHeaders(w http.ResponseWriter, opts ServeHeaderOptions) { } } -func serveSetHeadersByFileContent(w http.ResponseWriter, contentPrefetchBuf []byte, opts ServeHeaderOptions) { +func serveSetHeadersByUserContent(w http.ResponseWriter, contentPrefetchBuf []byte, opts ServeHeaderOptions) { if setting.MimeTypeMap.Enabled { fileExtension := strings.ToLower(path.Ext(opts.Filename)) opts.ContentType = setting.MimeTypeMap.Map[fileExtension] @@ -126,7 +126,7 @@ func ServeUserContentByReader(r *http.Request, w http.ResponseWriter, size int64 if n >= 0 { buf = buf[:n] } - serveSetHeadersByFileContent(w, buf, opts) + serveSetHeadersByUserContent(w, buf, opts) // reset the reader to the beginning reader = io.MultiReader(bytes.NewReader(buf), reader) From d5a62f29dc0930d06cea81952f0764d916a6846e Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 25 Mar 2026 22:09:53 +0800 Subject: [PATCH 77/78] fix default ContentDisposition --- modules/actions/artifacts.go | 5 ++--- modules/httplib/serve.go | 10 ++++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/modules/actions/artifacts.go b/modules/actions/artifacts.go index 3d5d0ed7d86d6..4884eb42e80d5 100644 --- a/modules/actions/artifacts.go +++ b/modules/actions/artifacts.go @@ -51,9 +51,8 @@ func DownloadArtifactV4ReadStorage(ctx *context.Base, art *actions_model.ActionA } defer f.Close() httplib.ServeUserContentByFile(ctx.Req, ctx.Resp, f, httplib.ServeHeaderOptions{ - Filename: art.ArtifactPath, - ContentType: art.ContentEncodingOrType, // v4 guarantees that the field is Content-Type - ContentDisposition: httplib.ContentDispositionInline, // allow to view the contents in browser (TODO: html is still rendered as text due to legacy logic) + Filename: art.ArtifactPath, + ContentType: art.ContentEncodingOrType, // v4 guarantees that the field is Content-Type }) return nil } diff --git a/modules/httplib/serve.go b/modules/httplib/serve.go index c3300506a1465..ae9754fbf803e 100644 --- a/modules/httplib/serve.go +++ b/modules/httplib/serve.go @@ -102,10 +102,12 @@ func serveSetHeadersByUserContent(w http.ResponseWriter, contentPrefetchBuf []by } } - sniffedType := typesniffer.FromContentType(opts.ContentType) - opts.ContentDisposition = ContentDispositionInline - if sniffedType.IsSvgImage() && !setting.UI.SVG.Enabled { - opts.ContentDisposition = ContentDispositionAttachment + if opts.ContentDisposition == "" { + sniffedType := typesniffer.FromContentType(opts.ContentType) + opts.ContentDisposition = ContentDispositionInline + if sniffedType.IsSvgImage() && !setting.UI.SVG.Enabled { + opts.ContentDisposition = ContentDispositionAttachment + } } ServeSetHeaders(w, opts) From 83231b52ddbe2b7218c692acb317bcdef16471fc Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 25 Mar 2026 23:34:20 +0800 Subject: [PATCH 78/78] clarify content-type charset detection --- modules/httplib/serve.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/modules/httplib/serve.go b/modules/httplib/serve.go index ae9754fbf803e..e8299d1c8052f 100644 --- a/modules/httplib/serve.go +++ b/modules/httplib/serve.go @@ -82,9 +82,12 @@ func ServeSetHeaders(w http.ResponseWriter, opts ServeHeaderOptions) { } func serveSetHeadersByUserContent(w http.ResponseWriter, contentPrefetchBuf []byte, opts ServeHeaderOptions) { + var detectCharset bool + if setting.MimeTypeMap.Enabled { fileExtension := strings.ToLower(path.Ext(opts.Filename)) opts.ContentType = setting.MimeTypeMap.Map[fileExtension] + detectCharset = !strings.Contains(opts.ContentType, "charset=") } if opts.ContentType == "" { @@ -94,14 +97,18 @@ func serveSetHeadersByUserContent(w http.ResponseWriter, contentPrefetchBuf []by } else if sniffedType.IsText() { // intentionally do not render user's HTML content as a page, for safety, and avoid content spamming & abusing opts.ContentType = "text/plain" - if charset, _ := charsetModule.DetectEncoding(contentPrefetchBuf); charset != "" { - opts.ContentType += "; charset=" + strings.ToLower(charset) - } + detectCharset = true } else { opts.ContentType = typesniffer.MimeTypeApplicationOctetStream } } + if detectCharset { + if charset, _ := charsetModule.DetectEncoding(contentPrefetchBuf); charset != "" { + opts.ContentType += "; charset=" + strings.ToLower(charset) + } + } + if opts.ContentDisposition == "" { sniffedType := typesniffer.FromContentType(opts.ContentType) opts.ContentDisposition = ContentDispositionInline