Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions models/migrations/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"gitea.dev/models/migrations/v1_25"
"gitea.dev/models/migrations/v1_26"
"gitea.dev/models/migrations/v1_27"
"gitea.dev/models/migrations/v1_28"
"gitea.dev/models/migrations/v1_6"
"gitea.dev/models/migrations/v1_7"
"gitea.dev/models/migrations/v1_8"
Expand Down Expand Up @@ -420,6 +421,7 @@ func prepareMigrationTasks() []*migration {
newMigration(340, "Add ContinueOnError column to ActionRunJob", v1_27.AddContinueOnErrorToActionRunJob),
newMigration(341, "Convert legacy MSSQL DATETIME columns to DATETIME2", v1_27.FixLegacyMSSQLDateTimeColumns),
newMigration(342, "Add scoped workflows schema", v1_27.AddScopedWorkflowsSchema),
newMigration(343, "Add sha256 checksum column to attachment", v1_28.AddChecksumsToAttachment),
}
return preparedMigrations
}
Expand Down
14 changes: 14 additions & 0 deletions models/migrations/v1_28/v343.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package v1_28

import "gitea.dev/models/db"

func AddChecksumsToAttachment(x db.EngineMigration) error {
type Attachment struct {
ID int64 `xorm:"pk autoincr"`
HashSHA256 string `xorm:"hash_sha256 VARCHAR(64) NOT NULL DEFAULT ''"`
}
return x.Sync(new(Attachment))
}
1 change: 1 addition & 0 deletions models/repo/attachment.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type Attachment struct {
UploaderID int64 `xorm:"INDEX DEFAULT 0"` // Notice: will be zero before this column added
CommentID int64 `xorm:"INDEX"`
Name string
HashSHA256 string `xorm:"hash_sha256 VARCHAR(64) NOT NULL DEFAULT ''"`
DownloadCount int64 `xorm:"DEFAULT 0"`
Size int64 `xorm:"DEFAULT 0"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
Expand Down
2 changes: 2 additions & 0 deletions modules/structs/attachment.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ type Attachment struct {
UUID string `json:"uuid"`
// DownloadURL is the URL to download the attachment
DownloadURL string `json:"browser_download_url"`
// HashSHA256 is the SHA256 hash of the attachment
HashSHA256 string `json:"sha256"`
}

// EditAttachmentOptions options for editing attachments
Expand Down
2 changes: 2 additions & 0 deletions routers/api/v1/repo/issue_attachment.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ func GetIssueAttachment(ctx *context.APIContext) {
if attach == nil {
return
}
attachment_service.EnsureSHA256(ctx, attach)

ctx.JSON(http.StatusOK, convert.ToAPIAttachment(ctx.Repo.Repository, attach))
}
Expand Down Expand Up @@ -109,6 +110,7 @@ func ListIssueAttachments(ctx *context.APIContext) {
ctx.APIErrorInternal(err)
return
}
attachment_service.EnsureSHA256(ctx, issue.Attachments...)

ctx.JSON(http.StatusOK, convert.ToAPIIssue(ctx, ctx.Doer, issue).Attachments)
}
Expand Down
2 changes: 2 additions & 0 deletions routers/api/v1/repo/issue_comment_attachment.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ func GetIssueCommentAttachment(ctx *context.APIContext) {
ctx.APIErrorNotFound("attachment not in comment")
return
}
attachment_service.EnsureSHA256(ctx, attachment)

ctx.JSON(http.StatusOK, convert.ToAPIAttachment(ctx.Repo.Repository, attachment))
}
Expand Down Expand Up @@ -113,6 +114,7 @@ func ListIssueCommentAttachments(ctx *context.APIContext) {
ctx.APIErrorInternal(err)
return
}
attachment_service.EnsureSHA256(ctx, comment.Attachments...)

ctx.JSON(http.StatusOK, convert.ToAPIAttachments(ctx.Repo.Repository, comment.Attachments))
}
Expand Down
4 changes: 4 additions & 0 deletions routers/api/v1/repo/release.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
api "gitea.dev/modules/structs"
"gitea.dev/modules/web"
"gitea.dev/routers/api/v1/utils"
attachment_service "gitea.dev/services/attachment"
"gitea.dev/services/context"
"gitea.dev/services/convert"
release_service "gitea.dev/services/release"
Expand Down Expand Up @@ -84,6 +85,7 @@ func GetRelease(ctx *context.APIContext) {
ctx.APIErrorInternal(err)
return
}
attachment_service.EnsureSHA256(ctx, release.Attachments...)
ctx.JSON(http.StatusOK, convert.ToAPIRelease(ctx, ctx.Repo.Repository, release))
}

Expand Down Expand Up @@ -125,6 +127,7 @@ func GetLatestRelease(ctx *context.APIContext) {
ctx.APIErrorInternal(err)
return
}
attachment_service.EnsureSHA256(ctx, release.Attachments...)
ctx.JSON(http.StatusOK, convert.ToAPIRelease(ctx, ctx.Repo.Repository, release))
}

Expand Down Expand Up @@ -191,6 +194,7 @@ func ListReleases(ctx *context.APIContext) {
ctx.APIErrorInternal(err)
return
}
attachment_service.EnsureSHA256(ctx, release.Attachments...)
rels[i] = convert.ToAPIRelease(ctx, ctx.Repo.Repository, release)
}

Expand Down
2 changes: 2 additions & 0 deletions routers/api/v1/repo/release_attachment.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ func GetReleaseAttachment(ctx *context.APIContext) {
ctx.APIErrorNotFound()
return
}
attachment_service.EnsureSHA256(ctx, attach)
// FIXME Should prove the existence of the given repo, but results in unnecessary database requests
ctx.JSON(http.StatusOK, convert.ToAPIAttachment(ctx.Repo.Repository, attach))
}
Expand Down Expand Up @@ -153,6 +154,7 @@ func ListReleaseAttachments(ctx *context.APIContext) {
ctx.APIErrorInternal(err)
return
}
attachment_service.EnsureSHA256(ctx, release.Attachments...)
ctx.JSON(http.StatusOK, convert.ToAPIRelease(ctx, ctx.Repo.Repository, release).Attachments)
}

Expand Down
2 changes: 2 additions & 0 deletions routers/api/v1/repo/release_tags.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

repo_model "gitea.dev/models/repo"
unit_model "gitea.dev/models/unit"
attachment_service "gitea.dev/services/attachment"
"gitea.dev/services/context"
"gitea.dev/services/convert"
release_service "gitea.dev/services/release"
Expand Down Expand Up @@ -70,6 +71,7 @@ func GetReleaseByTag(ctx *context.APIContext) {
ctx.APIErrorInternal(err)
return
}
attachment_service.EnsureSHA256(ctx, release.Attachments...)
ctx.JSON(http.StatusOK, convert.ToAPIRelease(ctx, ctx.Repo.Repository, release))
}

Expand Down
2 changes: 2 additions & 0 deletions routers/web/repo/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"gitea.dev/modules/util"
"gitea.dev/modules/web"
"gitea.dev/routers/common"
attachment_service "gitea.dev/services/attachment"
"gitea.dev/services/context"
"gitea.dev/services/convert"
"gitea.dev/services/forms"
Expand Down Expand Up @@ -579,6 +580,7 @@ func GetIssueAttachments(ctx *context.Context) {
if ctx.Written() {
return
}
attachment_service.EnsureSHA256(ctx, issue.Attachments...)
attachments := make([]*api.Attachment, len(issue.Attachments))
for i := 0; i < len(issue.Attachments); i++ {
attachments[i] = convert.ToAttachment(ctx.Repo.Repository, issue.Attachments[i])
Expand Down
2 changes: 2 additions & 0 deletions routers/web/repo/issue_comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
attachment_service "gitea.dev/services/attachment"
"gitea.dev/services/context"
"gitea.dev/services/convert"
"gitea.dev/services/forms"
Expand Down Expand Up @@ -444,6 +445,7 @@ func GetCommentAttachments(ctx *context.Context) {
ctx.ServerError("LoadAttachments", err)
return
}
attachment_service.EnsureSHA256(ctx, comment.Attachments...)
for i := 0; i < len(comment.Attachments); i++ {
attachments = append(attachments, convert.ToAttachment(ctx.Repo.Repository, comment.Attachments[i]))
}
Expand Down
4 changes: 4 additions & 0 deletions routers/web/repo/release.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"gitea.dev/modules/web"
"gitea.dev/routers/web/feed"
shared_user "gitea.dev/routers/web/shared/user"
attachment_service "gitea.dev/services/attachment"
"gitea.dev/services/context"
"gitea.dev/services/context/upload"
"gitea.dev/services/forms"
Expand Down Expand Up @@ -90,6 +91,9 @@ func getReleaseInfos(ctx *context.Context, opts *repo_model.FindReleasesOptions)
if err = repo_model.GetReleaseAttachments(ctx, releases...); err != nil {
return nil, err
}
for _, release := range releases {
attachment_service.EnsureSHA256(ctx, release.Attachments...)
}

// Temporary cache commits count of used branches to speed up.
countCache := make(map[string]int64)
Expand Down
6 changes: 5 additions & 1 deletion services/attachment/attachment.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ package attachment
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
Expand All @@ -29,11 +31,13 @@ func NewAttachment(ctx context.Context, attach *repo_model.Attachment, file io.R

err := db.WithTx(ctx, func(ctx context.Context) error {
attach.UUID = uuid.New().String()
size, err := storage.Attachments.Save(attach.RelativePath(), file, size)
hasher := sha256.New()
size, err := storage.Attachments.Save(attach.RelativePath(), io.TeeReader(file, hasher), size)
if err != nil {
return fmt.Errorf("Attachments.Save: %w", err)
}
attach.Size = size
attach.HashSHA256 = hex.EncodeToString(hasher.Sum(nil))
return db.Insert(ctx, attach)
})

Expand Down
7 changes: 7 additions & 0 deletions services/attachment/attachment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
package attachment

import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -42,4 +44,9 @@ func TestUploadAttachment(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, user.ID, attachment.UploaderID)
assert.Equal(t, int64(0), attachment.DownloadCount)

content, err := os.ReadFile(fPath)
assert.NoError(t, err)
sha256Sum := sha256.Sum256(content)
assert.Equal(t, hex.EncodeToString(sha256Sum[:]), attachment.HashSHA256)
}
51 changes: 51 additions & 0 deletions services/attachment/checksum.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package attachment

import (
"context"
"crypto/sha256"
"encoding/hex"
"io"

"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/modules/log"
"gitea.dev/modules/storage"
)

// EnsureSHA256 backfills missing attachment sha256 values on demand.
func EnsureSHA256(ctx context.Context, attachments ...*repo_model.Attachment) {
for _, attachment := range attachments {
if attachment == nil || attachment.ID == 0 || attachment.HashSHA256 != "" {
continue
}
if err := backfillSHA256(ctx, attachment); err != nil {
log.Warn("Unable to backfill attachment sha256 for %s: %v", attachment.UUID, err)
}
}
}

func backfillSHA256(ctx context.Context, attachment *repo_model.Attachment) error {
fr, err := storage.Attachments.Open(attachment.RelativePath())
if err != nil {
return err
}
defer fr.Close()

hasher := sha256.New()
if _, err := io.Copy(hasher, fr); err != nil {
return err
}

attach := &repo_model.Attachment{
ID: attachment.ID,
HashSHA256: hex.EncodeToString(hasher.Sum(nil)),
}
if _, err := db.GetEngine(ctx).ID(attachment.ID).Cols("hash_sha256").Update(attach); err != nil {
return err
}
attachment.HashSHA256 = attach.HashSHA256
return nil
}
1 change: 1 addition & 0 deletions services/convert/attachment.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ func toAttachment(repo *repo_model.Repository, a *repo_model.Attachment, getDown
Name: a.Name,
Created: a.CreatedUnix.AsTime(),
DownloadCount: a.DownloadCount,
HashSHA256: a.HashSHA256,
Size: a.Size,
UUID: a.UUID,
DownloadURL: getDownloadURL(repo, a), // for web request json and api request json, return different download urls
Expand Down
12 changes: 8 additions & 4 deletions templates/repo/release/list.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -80,15 +80,19 @@
<ul class="ui divided list attachment-list">
{{range $att := $release.Attachments}}
<li class="item">
<a target="_blank" class="tw-flex-1 gt-ellipsis" rel="nofollow" download href="{{$att.DownloadURL}}">
<a target="_blank" class="tw-flex-1 tw-min-w-0 gt-ellipsis" rel="nofollow" download href="{{$att.DownloadURL}}">
<strong class="flex-text-inline">{{svg "octicon-package" 16 "download-icon"}}<span class="gt-ellipsis">{{$att.Name}}</span></strong>
</a>
<div class="attachment-right-info flex-text-inline">
<span class="tw-pl-5">{{$att.Size | FileSize}}</span>
<div class="attachment-right-info flex-text-inline">
{{if $att.HashSHA256}}
<button class="btn interact-fg flex-text-inline tw-text-xs tw-font-mono tw-text-text-light tw-gap-1"
data-clipboard-text="{{$att.HashSHA256}}"
data-tooltip-content="SHA256: {{$att.HashSHA256}}">{{printf "%.12s" $att.HashSHA256}}{{svg "octicon-copy" 12}}</button>
{{end}}
<span>{{$att.Size | FileSize}}</span>
<span class="flex-text-inline" data-tooltip-content="{{ctx.Locale.Tr "repo.release.download_count" (ctx.Locale.PrettyNumber $att.DownloadCount)}}">
{{svg "octicon-info"}}
</span>
<div class="tw-flex-1"></div>
{{DateUtils.TimeSince $att.CreatedUnix}}
</div>
</li>
Expand Down
5 changes: 5 additions & 0 deletions templates/swagger/v1_json.tmpl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions templates/swagger/v1_openapi3_json.tmpl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 40 additions & 1 deletion tests/e2e/release.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {env} from 'node:process';
import {createHash} from 'node:crypto';
import {test, expect} from '@playwright/test';
import {login, apiCreateRepo, randomString} from './utils.ts';
import {login, apiCreateRepo, apiHeaders, baseUrl, randomString} from './utils.ts';

test('create a release', async ({page, request}) => {
const repoName = `e2e-release-${randomString(8)}`;
Expand All @@ -17,3 +18,41 @@ test('create a release', async ({page, request}) => {
await page.waitForURL(new RegExp(`/${owner}/${repoName}/releases$`));
await expect(page.locator('.release-list-title')).toContainText(title);
});

test('show sha256 for release attachments', async ({page, request}) => {
const repoName = `e2e-release-assets-${randomString(8)}`;
const owner = env.GITEA_TEST_E2E_USER;
const tag = `v1.0.0-${randomString(8)}`;
const title = `asset-release-${randomString(8)}`;
const filename = 'checksum.txt';
const content = Buffer.from(`release attachment ${randomString(16)}`, 'utf8');
const sha256 = createHash('sha256').update(content).digest('hex');

await Promise.all([apiCreateRepo(request, {name: repoName}), login(page)]);

const releaseResponse = await request.post(`${baseUrl()}/api/v1/repos/${owner}/${repoName}/releases`, {
headers: apiHeaders(),
data: {tag_name: tag, name: title, body: 'release attachment checksum test'},
});
expect(releaseResponse.ok()).toBeTruthy();
const release = await releaseResponse.json();

const assetResponse = await request.post(`${baseUrl()}/api/v1/repos/${owner}/${repoName}/releases/${release.id}/assets`, {
headers: apiHeaders(),
multipart: {
attachment: {
name: filename,
mimeType: 'text/plain',
buffer: content,
},
},
});
expect(assetResponse.ok()).toBeTruthy();

await page.goto(`/${owner}/${repoName}/releases`);
const attachmentItem = page.locator('.attachment-list .item').filter({hasText: filename});
await expect(attachmentItem).toContainText(filename);
// Button shows the first 12 chars, tooltip holds the full hash
const checksumBtn = attachmentItem.locator(`button[data-clipboard-text="${sha256}"]`);
await expect(checksumBtn).toContainText(sha256.slice(0, 12));
});
Loading
Loading