Compare commits
10
Commits
f81f47ee90
...
7bb1f0aba4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bb1f0aba4 | ||
|
|
f914c4fade | ||
|
|
19d1e1d334 | ||
|
|
920b3f8cb6 | ||
|
|
4ba0a545f2 | ||
|
|
a51781527b | ||
|
|
7134c1f845 | ||
|
|
6e1751fcd1
|
||
|
|
7b4a1a1a11 | ||
|
|
63df886ba8 |
@@ -64,6 +64,11 @@ jobs:
|
||||
- ".golangci.yml"
|
||||
- ".editorconfig"
|
||||
- "options/locale/locale_en-US.json"
|
||||
- "models/fixtures/**"
|
||||
- "tests/*.ini.tmpl"
|
||||
- "tests/gitea-repositories-meta/**"
|
||||
- "tests/testdata/**"
|
||||
- "tools/test-integration.sh"
|
||||
|
||||
frontend:
|
||||
- "*.ts"
|
||||
|
||||
@@ -505,13 +505,19 @@ func NewCommitStatus(ctx context.Context, opts NewCommitStatusOptions) error {
|
||||
opts.CommitStatus.Description = strings.TrimSpace(opts.CommitStatus.Description)
|
||||
opts.CommitStatus.Context = strings.TrimSpace(opts.CommitStatus.Context)
|
||||
opts.CommitStatus.TargetURL = strings.TrimSpace(opts.CommitStatus.TargetURL)
|
||||
opts.CommitStatus.ContextHash = strings.TrimSpace(opts.CommitStatus.ContextHash)
|
||||
opts.CommitStatus.SHA = opts.SHA.String()
|
||||
opts.CommitStatus.CreatorID = opts.Creator.ID
|
||||
opts.CommitStatus.RepoID = opts.Repo.ID
|
||||
opts.CommitStatus.Index = idx
|
||||
log.Debug("NewCommitStatus[%s, %s]: %d", opts.Repo.FullName(), opts.SHA, opts.CommitStatus.Index)
|
||||
|
||||
opts.CommitStatus.ContextHash = hashCommitStatusContext(opts.CommitStatus.Context)
|
||||
// Callers may pre-compute a ContextHash to keep entries that share a
|
||||
// human-readable Context separated (e.g. two workflow files with the
|
||||
// same `name:` — issue #35699). Only derive from Context when unset.
|
||||
if opts.CommitStatus.ContextHash == "" {
|
||||
opts.CommitStatus.ContextHash = HashCommitStatusContext(opts.CommitStatus.Context)
|
||||
}
|
||||
|
||||
// Insert new CommitStatus
|
||||
if err = db.Insert(ctx, opts.CommitStatus); err != nil {
|
||||
@@ -529,8 +535,11 @@ type SignCommitWithStatuses struct {
|
||||
*asymkey_model.SignCommit
|
||||
}
|
||||
|
||||
// hashCommitStatusContext hash context
|
||||
func hashCommitStatusContext(context string) string {
|
||||
// HashCommitStatusContext returns the sha1 hash used to dedupe commit statuses
|
||||
// by Context. Callers that need to keep statuses with the same display Context
|
||||
// separated (e.g. distinct workflow files sharing a `name:`) can mix extra
|
||||
// disambiguating data into the input.
|
||||
func HashCommitStatusContext(context string) string {
|
||||
return fmt.Sprintf("%x", sha1.Sum([]byte(context)))
|
||||
}
|
||||
|
||||
|
||||
+5
-2
@@ -196,7 +196,10 @@ func LFSObjectAccessible(ctx context.Context, user *user_model.User, oid string)
|
||||
count, err := db.GetEngine(ctx).Count(&LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}})
|
||||
return count > 0, err
|
||||
}
|
||||
cond := repo_model.AccessibleRepositoryCondition(user, unit.TypeInvalid)
|
||||
// LFS objects are repository code content, so authorization must require
|
||||
// Code-unit access; other unit accesses (e.g. Issues) must not authorize
|
||||
// reuse of an existing LFS object across repositories.
|
||||
cond := repo_model.AccessibleRepositoryCondition(user, unit.TypeCode)
|
||||
count, err := db.GetEngine(ctx).Where(cond).Join("INNER", "repository", "`lfs_meta_object`.repository_id = `repository`.id").Count(&LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}})
|
||||
return count > 0, err
|
||||
}
|
||||
@@ -220,7 +223,7 @@ func LFSAutoAssociate(ctx context.Context, metas []*LFSMetaObject, user *user_mo
|
||||
newMetas := make([]*LFSMetaObject, 0, len(metas))
|
||||
cond := builder.In(
|
||||
"`lfs_meta_object`.repository_id",
|
||||
builder.Select("`repository`.id").From("repository").Where(repo_model.AccessibleRepositoryCondition(user, unit.TypeInvalid)),
|
||||
builder.Select("`repository`.id").From("repository").Where(repo_model.AccessibleRepositoryCondition(user, unit.TypeCode)),
|
||||
)
|
||||
if err := db.GetEngine(ctx).Cols("oid").Where(cond).In("oid", oids...).GroupBy("oid").Find(&newMetas); err != nil {
|
||||
return err
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// HostMatchList is used to check if a host or IP is in a list.
|
||||
@@ -23,10 +24,64 @@ type HostMatchList struct {
|
||||
ipNets []*net.IPNet
|
||||
}
|
||||
|
||||
// MatchBuiltinExternal A valid non-private unicast IP, all hosts on public internet are matched
|
||||
// MatchBuiltinExternal A valid global-unicast IP that is neither private (see MatchBuiltinPrivate)
|
||||
// nor a reserved special-purpose range (see reservedIPNets); i.e. a routable host on the public internet.
|
||||
const MatchBuiltinExternal = "external"
|
||||
|
||||
// MatchBuiltinPrivate RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7). Also called LAN/Intranet.
|
||||
// reservedIPNets are special-purpose ranges that net.IP.IsPrivate omits but that must not be
|
||||
// treated as public/external destinations (CGNAT, cloud metadata, IPv6 transition, etc.). We layer
|
||||
// these on top of net.IP.IsPrivate (RFC 1918 / RFC 4193) so future additions to Go's IsPrivate are
|
||||
// picked up automatically, while still covering the ranges it leaves out; otherwise the default
|
||||
// allow-list would let authenticated users reach cloud metadata, internal, and IPv6 transition
|
||||
// endpoints (SSRF), and a "private" block-list would fail to catch them.
|
||||
var reservedIPNets = sync.OnceValue(func() []*net.IPNet {
|
||||
var nets []*net.IPNet
|
||||
for _, cidr := range []string{
|
||||
// IPv4
|
||||
"100.64.0.0/10", // RFC 6598 Carrier-Grade NAT
|
||||
"168.63.129.16/32", // Azure WireServer metadata endpoint
|
||||
"192.0.0.0/24", // RFC 6890 IETF protocol assignments
|
||||
"192.0.2.0/24", // RFC 5737 TEST-NET-1
|
||||
"192.88.99.0/24", // RFC 7526 6to4 relay anycast (deprecated)
|
||||
"198.18.0.0/15", // RFC 2544 benchmarking
|
||||
"198.51.100.0/24", // RFC 5737 TEST-NET-2
|
||||
"203.0.113.0/24", // RFC 5737 TEST-NET-3
|
||||
// IPv6
|
||||
"100::/64", // RFC 6666 discard-only
|
||||
"64:ff9b::/96", // RFC 6052 NAT64 (can embed IPv4 such as 169.254.169.254)
|
||||
"64:ff9b:1::/48", // RFC 8215 local-use NAT64
|
||||
"2001::/32", // RFC 4380 Teredo tunneling (embeds IPv4)
|
||||
"2001:10::/28", // RFC 4843 ORCHID (deprecated)
|
||||
"2001:20::/28", // RFC 7343 ORCHIDv2
|
||||
"2001:db8::/32", // RFC 3849 documentation
|
||||
"2002::/16", // RFC 3056 6to4 (embeds IPv4)
|
||||
} {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
panic("hostmatcher: invalid reserved CIDR " + cidr + ": " + err.Error())
|
||||
}
|
||||
nets = append(nets, ipNet)
|
||||
}
|
||||
return nets
|
||||
})
|
||||
|
||||
// isPrivateIP reports whether ip falls in a private (net.IP.IsPrivate) or reserved special-purpose
|
||||
// range (see reservedIPNets) that must not be considered a public/external destination.
|
||||
func isPrivateIP(ip net.IP) bool {
|
||||
if ip.IsPrivate() {
|
||||
return true
|
||||
}
|
||||
for _, ipNet := range reservedIPNets() {
|
||||
if ipNet.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MatchBuiltinPrivate RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7),
|
||||
// plus the reserved special-purpose ranges in reservedIPNets (CGNAT, NAT64, cloud metadata, etc.).
|
||||
// Also called LAN/Intranet.
|
||||
const MatchBuiltinPrivate = "private"
|
||||
|
||||
// MatchBuiltinLoopback 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included.
|
||||
@@ -100,11 +155,11 @@ func (hl *HostMatchList) checkIP(ip net.IP) bool {
|
||||
for _, builtin := range hl.builtins {
|
||||
switch builtin {
|
||||
case MatchBuiltinExternal:
|
||||
if ip.IsGlobalUnicast() && !ip.IsPrivate() {
|
||||
if ip.IsGlobalUnicast() && !isPrivateIP(ip) {
|
||||
return true
|
||||
}
|
||||
case MatchBuiltinPrivate:
|
||||
if ip.IsPrivate() {
|
||||
if isPrivateIP(ip) {
|
||||
return true
|
||||
}
|
||||
case MatchBuiltinLoopback:
|
||||
|
||||
@@ -159,3 +159,58 @@ func TestHostOrIPMatchesList(t *testing.T) {
|
||||
}
|
||||
test(cases)
|
||||
}
|
||||
|
||||
// TestReservedRanges ensures special-purpose ranges that net.IP.IsPrivate misses are kept out of the
|
||||
// "external" allow-list (the default for webhook delivery and repository migrations) and folded into
|
||||
// the "private" block-list, so they cannot be used for SSRF to metadata/internal endpoints.
|
||||
func TestReservedRanges(t *testing.T) {
|
||||
external := ParseHostMatchList("", "external")
|
||||
private := ParseHostMatchList("", "private")
|
||||
|
||||
// legitimate public destinations: external, not private
|
||||
for _, ip := range []string{"8.8.8.8", "1.1.1.1", "2001:4860:4860::8888", "1000::1"} {
|
||||
addr := net.ParseIP(ip)
|
||||
assert.Truef(t, external.MatchIPAddr(addr), "public ip %s should be external", ip)
|
||||
assert.Falsef(t, private.MatchIPAddr(addr), "public ip %s should not be private", ip)
|
||||
}
|
||||
|
||||
// RFC 1918 / RFC 4193 private ranges (now folded into privateIPNets instead of net.IP.IsPrivate):
|
||||
// not external, blockable as private. Includes range edges to guard the CIDR boundaries.
|
||||
for _, ip := range []string{
|
||||
"10.0.0.0", "10.255.255.255", // 10.0.0.0/8
|
||||
"172.16.0.0", "172.31.255.255", // 172.16.0.0/12
|
||||
"192.168.0.0", "192.168.255.255", // 192.168.0.0/16
|
||||
"fc00::", "fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", // fc00::/7
|
||||
} {
|
||||
addr := net.ParseIP(ip)
|
||||
assert.Falsef(t, external.MatchIPAddr(addr), "private ip %s must not be external", ip)
|
||||
assert.Truef(t, private.MatchIPAddr(addr), "private ip %s should match private block-list", ip)
|
||||
}
|
||||
|
||||
// 172.32.0.0 is just outside 172.16.0.0/12: a public destination, not private
|
||||
if addr := net.ParseIP("172.32.0.0"); assert.NotNil(t, addr) {
|
||||
assert.True(t, external.MatchIPAddr(addr), "172.32.0.0 should be external")
|
||||
assert.False(t, private.MatchIPAddr(addr), "172.32.0.0 should not be private")
|
||||
}
|
||||
|
||||
// reserved ranges that IsPrivate does not cover: not external, but blockable as private
|
||||
for _, ip := range []string{
|
||||
"100.64.0.1", // CGNAT
|
||||
"100.127.255.254", // CGNAT
|
||||
"168.63.129.16", // Azure WireServer
|
||||
"192.0.2.1", // TEST-NET-1
|
||||
"198.18.0.1", // benchmarking
|
||||
"198.51.100.1", // TEST-NET-2
|
||||
"203.0.113.1", // TEST-NET-3
|
||||
"192.88.99.1", // 6to4 relay anycast
|
||||
"64:ff9b::1", // NAT64
|
||||
"64:ff9b::a9fe:a9fe", // NAT64 embedding 169.254.169.254
|
||||
"2001::1", // Teredo
|
||||
"2002::1", // 6to4
|
||||
"2001:db8::1", // documentation
|
||||
} {
|
||||
addr := net.ParseIP(ip)
|
||||
assert.Falsef(t, external.MatchIPAddr(addr), "reserved ip %s must not be external", ip)
|
||||
assert.Truef(t, private.MatchIPAddr(addr), "reserved ip %s should match private block-list", ip)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,15 +146,26 @@ func ParseControlFile(r io.Reader) (*Package, error) {
|
||||
var depends strings.Builder
|
||||
var control strings.Builder
|
||||
|
||||
s := bufio.NewScanner(io.TeeReader(r, &control))
|
||||
// https://www.debian.org/doc/debian-policy/ch-controlfields.html#syntax-of-control-files
|
||||
s := bufio.NewScanner(r)
|
||||
for s.Scan() {
|
||||
line := s.Text()
|
||||
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
// A binary package control file holds exactly one stanza. Stop at the
|
||||
// blank line that terminates it, otherwise a crafted control file could
|
||||
// smuggle additional stanzas (with attacker-chosen Filename/Package
|
||||
// fields) into the generated repository "Packages" index.
|
||||
if control.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
control.WriteString(line)
|
||||
control.WriteByte('\n')
|
||||
|
||||
if line[0] == ' ' || line[0] == '\t' {
|
||||
switch key {
|
||||
case "Description":
|
||||
|
||||
@@ -184,4 +184,19 @@ func TestParseControlFile(t *testing.T) {
|
||||
assert.NotNil(t, p)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SingleStanzaOnly", func(t *testing.T) {
|
||||
// A control file with a trailing stanza must not leak the extra fields into
|
||||
// p.Control, otherwise buildPackagesIndices would emit a second package entry
|
||||
// with an attacker-chosen Filename into the repository "Packages" index.
|
||||
content := bytes.NewBufferString("Package: realpkg\nVersion: 1.0.0\nArchitecture: amd64\nMaintainer: a <a@b.c>\nDescription: real\n\nPackage: openssl\nVersion: 99.0\nArchitecture: amd64\nFilename: pool/main/o/openssl/evil.deb\nDescription: spoofed\n")
|
||||
|
||||
p, err := ParseControlFile(content)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, p)
|
||||
assert.Equal(t, "realpkg", p.Name)
|
||||
assert.Equal(t, "1.0.0", p.Version)
|
||||
assert.NotContains(t, p.Control, "openssl")
|
||||
assert.NotContains(t, p.Control, "evil.deb")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -139,10 +139,24 @@ func getCommitStatusEventNameAndCommitID(run *actions_model.ActionRun) (event, c
|
||||
func createCommitStatus(ctx context.Context, repo *repo_model.Repository, event, commitID string, run *actions_model.ActionRun, job *actions_model.ActionRunJob) error {
|
||||
// TODO: store workflow name as a field in ActionRun to avoid parsing
|
||||
runName := path.Base(run.WorkflowID)
|
||||
// fall back to the file name when the workflow has no non-blank `name:`
|
||||
if wfs, err := jobparser.Parse(job.WorkflowPayload); err == nil && len(wfs) > 0 {
|
||||
runName = wfs[0].Name
|
||||
if name := strings.TrimSpace(wfs[0].Name); name != "" {
|
||||
runName = name
|
||||
}
|
||||
}
|
||||
ctxName := strings.TrimSpace(fmt.Sprintf("%s / %s (%s)", runName, job.Name, event)) // git_model.NewCommitStatus also trims spaces
|
||||
// Mix the workflow file path into the hash so two workflow files that
|
||||
// share the same `name:` and job name produce distinct commit statuses
|
||||
// even though they render identically — matching GitHub's behavior
|
||||
// (issue #35699).
|
||||
ctxHash := git_model.HashCommitStatusContext(ctxName + "\x00" + run.WorkflowID)
|
||||
// Pre-fix rows were hashed from Context alone. If a pre-existing row with
|
||||
// the legacy hash is still the "latest" for this SHA, reuse that hash so
|
||||
// the new row supersedes it; otherwise the old pending status would stay
|
||||
// stuck forever (it lives in its own dedupe group). Only relevant for
|
||||
// in-flight workflows at upgrade time.
|
||||
legacyHash := git_model.HashCommitStatusContext(ctxName)
|
||||
state := toCommitStatus(job.Status)
|
||||
targetURL := fmt.Sprintf("%s/jobs/%d", run.Link(), job.ID)
|
||||
description := toCommitStatusDescription(job)
|
||||
@@ -152,7 +166,13 @@ func createCommitStatus(ctx context.Context, repo *repo_model.Repository, event,
|
||||
return fmt.Errorf("GetLatestCommitStatus: %w", err)
|
||||
}
|
||||
for _, v := range statuses {
|
||||
if v.Context == ctxName {
|
||||
if v.ContextHash == legacyHash && v.Context == ctxName {
|
||||
ctxHash = legacyHash
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, v := range statuses {
|
||||
if v.ContextHash == ctxHash {
|
||||
if v.State == state && v.TargetURL == targetURL && v.Description == description {
|
||||
return nil
|
||||
}
|
||||
@@ -166,6 +186,7 @@ func createCommitStatus(ctx context.Context, repo *repo_model.Repository, event,
|
||||
TargetURL: targetURL,
|
||||
Description: description,
|
||||
Context: ctxName,
|
||||
ContextHash: ctxHash,
|
||||
State: state,
|
||||
CreatorID: creator.ID,
|
||||
}
|
||||
|
||||
@@ -11,8 +11,10 @@ import (
|
||||
git_model "gitea.dev/models/git"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/commitstatus"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
@@ -146,6 +148,158 @@ func TestGetCommitActionsStatusMap(t *testing.T) {
|
||||
assert.Empty(t, nilInfo.IconStatus(statuses[0]))
|
||||
}
|
||||
|
||||
// TestCreateCommitStatus_DistinctWorkflowFilesSameName covers issue #35699:
|
||||
// two workflow files with the same `name:` and same job name must produce
|
||||
// two distinct commit statuses, not be deduplicated into one.
|
||||
func TestCreateCommitStatus_DistinctWorkflowFilesSameName(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch})
|
||||
|
||||
payload := []byte(`
|
||||
name: test-run
|
||||
on: pull_request
|
||||
jobs:
|
||||
my-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi
|
||||
`)
|
||||
|
||||
for _, spec := range []struct {
|
||||
workflowID string
|
||||
runID, jobID int64
|
||||
}{
|
||||
{"workflow1.yaml", 99101, 99201},
|
||||
{"workflow2.yaml", 99102, 99202},
|
||||
} {
|
||||
run := &actions_model.ActionRun{
|
||||
ID: spec.runID, Index: spec.runID, RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, TriggerUserID: repo.OwnerID,
|
||||
WorkflowID: spec.workflowID, CommitSHA: branch.CommitID,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
job := &actions_model.ActionRunJob{
|
||||
ID: spec.jobID, RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID,
|
||||
Name: "my-test", Status: actions_model.StatusWaiting,
|
||||
WorkflowPayload: payload,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
require.NoError(t, createCommitStatus(t.Context(), repo, "pull_request", branch.CommitID, run, job))
|
||||
}
|
||||
|
||||
statuses, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Both workflow files should produce a row even though the display
|
||||
// Context is identical — matching GitHub's behavior.
|
||||
hashes := map[string]struct{}{}
|
||||
targets := map[string]struct{}{}
|
||||
for _, st := range statuses {
|
||||
hashes[st.ContextHash] = struct{}{}
|
||||
targets[st.TargetURL] = struct{}{}
|
||||
assert.Equal(t, "test-run / my-test (pull_request)", st.Context)
|
||||
}
|
||||
assert.Len(t, hashes, 2, "expected distinct ContextHash per workflow file")
|
||||
assert.Len(t, targets, 2, "expected distinct TargetURL per workflow file")
|
||||
}
|
||||
|
||||
// TestCreateCommitStatus_LegacyHashRecovery covers the upgrade path: a pending
|
||||
// status created before the fix (hashed from Context alone) must still be
|
||||
// superseded by a follow-up event, instead of being orphaned in its own dedupe
|
||||
// group while a new row accumulates under the new hash.
|
||||
func TestCreateCommitStatus_LegacyHashRecovery(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch})
|
||||
|
||||
ctxName := "legacy.yaml / my-job (push)"
|
||||
legacyHash := git_model.HashCommitStatusContext(ctxName)
|
||||
sha, err := git.NewIDFromString(branch.CommitID)
|
||||
require.NoError(t, err)
|
||||
creator := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
require.NoError(t, git_model.NewCommitStatus(t.Context(), git_model.NewCommitStatusOptions{
|
||||
Repo: repo,
|
||||
Creator: creator,
|
||||
SHA: sha,
|
||||
CommitStatus: &git_model.CommitStatus{
|
||||
State: commitstatus.CommitStatusPending,
|
||||
Context: ctxName,
|
||||
ContextHash: legacyHash,
|
||||
TargetURL: "https://example.invalid/legacy",
|
||||
Description: "Waiting to run",
|
||||
},
|
||||
}))
|
||||
|
||||
run := &actions_model.ActionRun{
|
||||
ID: 99301, Index: 99301, RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, TriggerUserID: repo.OwnerID,
|
||||
WorkflowID: "legacy.yaml", CommitSHA: branch.CommitID,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
job := &actions_model.ActionRunJob{
|
||||
ID: 99302, RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID,
|
||||
Name: "my-job", Status: actions_model.StatusSuccess,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, run, job))
|
||||
|
||||
latest, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll)
|
||||
require.NoError(t, err)
|
||||
// The new row must reuse the legacy hash so GetLatestCommitStatus returns
|
||||
// only one entry for this Context — the success, not the orphaned pending.
|
||||
matches := 0
|
||||
for _, s := range latest {
|
||||
if s.Context == ctxName {
|
||||
matches++
|
||||
assert.Equal(t, legacyHash, s.ContextHash)
|
||||
assert.Equal(t, commitstatus.CommitStatusSuccess, s.State)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, matches)
|
||||
}
|
||||
|
||||
// TestCreateCommitStatus_UnnamedWorkflowUsesFileName: a workflow with no
|
||||
// non-blank `name:` uses the file name in the Context, not an empty
|
||||
// "/ job (event)" — covers both an omitted and a whitespace-only name.
|
||||
func TestCreateCommitStatus_UnnamedWorkflowUsesFileName(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch})
|
||||
|
||||
for _, tc := range []struct {
|
||||
workflowID string
|
||||
runID, jobID int64
|
||||
payload string
|
||||
}{
|
||||
{"unnamed.yaml", 99401, 99411, "on: push\n"},
|
||||
{"blank.yaml", 99402, 99412, "name: \" \"\non: push\n"},
|
||||
} {
|
||||
run := &actions_model.ActionRun{
|
||||
ID: tc.runID, Index: tc.runID, RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, TriggerUserID: repo.OwnerID,
|
||||
WorkflowID: tc.workflowID, CommitSHA: branch.CommitID,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
job := &actions_model.ActionRunJob{
|
||||
ID: tc.jobID, RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID,
|
||||
Name: "my-test", Status: actions_model.StatusWaiting,
|
||||
WorkflowPayload: []byte(tc.payload + `jobs:
|
||||
my-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi
|
||||
`),
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, run, job))
|
||||
|
||||
statuses := findCommitStatusesForContext(t, repo.ID, branch.CommitID, tc.workflowID+" / my-test (push)")
|
||||
require.Len(t, statuses, 1)
|
||||
assert.Equal(t, commitstatus.CommitStatusPending, statuses[0].State)
|
||||
}
|
||||
}
|
||||
|
||||
func findCommitStatusesForContext(t *testing.T, repoID int64, sha, context string) []*git_model.CommitStatus {
|
||||
t.Helper()
|
||||
|
||||
|
||||
+60
-58
@@ -1,71 +1,73 @@
|
||||
{{template "base/head" .}}
|
||||
<div role="main" aria-label="M8SH Project" class="page-content home">
|
||||
<div class="tw-mb-8 tw-px-8">
|
||||
<div class="center">
|
||||
<div class="hero">
|
||||
<h1 class="ui icon header title tw-text-balance"
|
||||
style="font-size: 3.5rem; letter-spacing: -1px; margin-bottom: 0.3rem;">
|
||||
M8SH
|
||||
</h1>
|
||||
<h2 class="tw-text-balance"
|
||||
style="color: var(--color-primary); font-style: italic; margin-bottom: 0.5rem;">
|
||||
What web should have have received years ago.
|
||||
</h2>
|
||||
<p class="large tw-text-balance" style="opacity: 0.7; margin-bottom: 2rem;">
|
||||
Heavily extended fork of Gitea with integrated VPN, email, messenger, media streaming, gpg-auth and
|
||||
more...
|
||||
</p>
|
||||
<div class="ui message tw-text-left" style="max-width: 800px; margin: 0 auto;">
|
||||
<p><strong>M8SH</strong> is a decentralized network using the well-known paradigm of email addresses
|
||||
for user identification — but offering much more. All powered by a decentralized network, with
|
||||
the goal of replacing monopolized data markets and restoring our freedom to own, distribute, and
|
||||
sell different forms of data. Rules of distribution and monetization should be decided by the
|
||||
community - not a hegemon.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw-max-w-3xl tw-mx-auto tw-px-6 tw-py-16">
|
||||
|
||||
<div class="ui stackable middle very relaxed page grid">
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
🔍 Search
|
||||
<div class="tw-mb-12">
|
||||
<h1 style="font-size: 3rem; font-weight: 700; letter-spacing: -2px; margin: 0 0 0.25rem;">
|
||||
M8SH
|
||||
</h1>
|
||||
<p class="large tw-text-balance">
|
||||
Each server has an integrated search engine that can search across other instances. Public content
|
||||
accessible via regular websites.
|
||||
<p style="color: var(--color-primary); font-size: 0.9rem; text-transform: uppercase; letter-spacing: 2px; margin: 0 0 1rem;">
|
||||
Work in progress
|
||||
</p>
|
||||
<p style="font-size: 1.15rem; color: var(--color-text-secondary); margin: 0;">
|
||||
A heavily extended Gitea fork - integrated VPN, email, messenger, media streaming, GPG-based auth, and more.
|
||||
</p>
|
||||
</div>
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
🔐 Pure Cryptography
|
||||
</h1>
|
||||
<p class="large tw-text-balance">
|
||||
No passwords. No sessions. No KYC. Keypair authentication with signature verification against your home
|
||||
server.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui stackable middle very relaxed page grid">
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
💬 Messenger & Cloud
|
||||
</h1>
|
||||
<p class="large tw-text-balance">
|
||||
Built-in messenger with end-to-end cryptographic guarantees. Content over 50 MB redistributed via
|
||||
BitTorrent.
|
||||
<div style="border-top: 1px solid var(--color-border-primary); padding-top: 2rem; margin-bottom: 2rem;">
|
||||
<p style="line-height: 1.8; margin-bottom: 1rem;">
|
||||
M8SH is what email should have evolved into, but didn't.
|
||||
</p>
|
||||
<p style="line-height: 1.8; margin-bottom: 1rem; color: var(--color-text-secondary);">
|
||||
The naming convention stays - <code>name@example.com</code> - because that part was always right.
|
||||
What email got right was federation: anyone could run a server and communicate freely, without depending on proprietary software.
|
||||
What it got catastrophically wrong was everything else - cryptography, security, protocols, user experience.
|
||||
Ray Tomlinson's invention served its purpose in the 70s. Then corporations arrived, offered convenience in exchange for privacy,
|
||||
and the protocol was effectively abandoned. GPG extensions were proposed and ignored, because they were inconvenient for the corps.
|
||||
</p>
|
||||
<p style="line-height: 1.8; color: var(--color-text-secondary);">
|
||||
M8SH turns Gitea from a development platform into a complete content exchange and distribution system —
|
||||
decentralized, no dozen separate accounts, no walled gardens. The one thing email got right —
|
||||
<code>user@domain</code> — stays as the identity primitive, rebuilt on modern protocols and cryptography.
|
||||
</p>
|
||||
</div>
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
🎵 Multimedia
|
||||
</h1>
|
||||
<p class="large tw-text-balance">
|
||||
Full multimedia support: music, reels, videos, streams. Content is fingerprinted — copies are detected
|
||||
and never shown.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<h3 style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 2px; color: var(--color-text-secondary); margin-bottom: 1rem;">
|
||||
Roadmap
|
||||
</h3>
|
||||
<ol style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.75rem;">
|
||||
<li style="display: flex; gap: 1rem; align-items: baseline;">
|
||||
<span style="font-size: 0.75rem; color: var(--color-text-secondary); min-width: 1.5rem; font-variant-numeric: tabular-nums;">01</span>
|
||||
<span>GPG-based registration, auth and authentication - decentralized by design</span>
|
||||
</li>
|
||||
<li style="display: flex; gap: 1rem; align-items: baseline;">
|
||||
<span style="font-size: 0.75rem; color: var(--color-text-secondary); min-width: 1.5rem; font-variant-numeric: tabular-nums;">02</span>
|
||||
<span>Integrated email server - for compatibility with legacy systems</span>
|
||||
</li>
|
||||
<li style="display: flex; gap: 1rem; align-items: baseline;">
|
||||
<span style="font-size: 0.75rem; color: var(--color-text-secondary); min-width: 1.5rem; font-variant-numeric: tabular-nums;">03</span>
|
||||
<span>Integrated messenger - end-to-end encrypted via GPG public keys</span>
|
||||
</li>
|
||||
<li style="display: flex; gap: 1rem; align-items: baseline;">
|
||||
<span style="font-size: 0.75rem; color: var(--color-text-secondary); min-width: 1.5rem; font-variant-numeric: tabular-nums;">04</span>
|
||||
<span>Federated search - indexed across M8SH nodes, accessible via API</span>
|
||||
</li>
|
||||
<li style="display: flex; gap: 1rem; align-items: baseline;">
|
||||
<span style="font-size: 0.75rem; color: var(--color-text-secondary); min-width: 1.5rem; font-variant-numeric: tabular-nums;">05</span>
|
||||
<span>Posts, articles, videos, reels, music</span>
|
||||
</li>
|
||||
<li style="display: flex; gap: 1rem; align-items: baseline;">
|
||||
<span style="font-size: 0.75rem; color: var(--color-text-secondary); min-width: 1.5rem; font-variant-numeric: tabular-nums;">06</span>
|
||||
<span>Integrated VPN</span>
|
||||
</li>
|
||||
<li style="display: flex; gap: 1rem; align-items: baseline;">
|
||||
<span style="font-size: 0.75rem; color: var(--color-text-secondary); min-width: 1.5rem; font-variant-numeric: tabular-nums;">07</span>
|
||||
<span>Encrypted cloud storage</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
@@ -129,11 +129,7 @@
|
||||
<div class="flex-text-inline">
|
||||
{{if .Author}}
|
||||
{{ctx.AvatarUtils.Avatar .Author 20}}
|
||||
{{if .Author.FullName}}
|
||||
<a href="{{.Author.HomeLink}}"><strong>{{.Author.FullName}}</strong></a>
|
||||
{{else}}
|
||||
<a href="{{.Author.HomeLink}}"><strong>{{.Commit.Author.Name}}</strong></a>
|
||||
{{end}}
|
||||
<strong>{{.Author.GetShortDisplayNameLinkHTML}}</strong>
|
||||
{{else}}
|
||||
{{ctx.AvatarUtils.AvatarByEmail .Commit.Author.Email .Commit.Author.Email 20}}
|
||||
<strong>{{.Commit.Author.Name}}</strong>
|
||||
@@ -141,18 +137,19 @@
|
||||
</div>
|
||||
<span class="tw-text-text-light">{{DateUtils.TimeSince .Commit.Committer.When}}</span>
|
||||
|
||||
<div class="flex-text-inline">
|
||||
{{if or (ne .Commit.Committer.Name .Commit.Author.Name) (ne .Commit.Committer.Email .Commit.Author.Email)}}
|
||||
{{$committerIsAuthor := and (eq .Commit.Committer.Name .Commit.Author.Name) (eq .Commit.Committer.Email .Commit.Author.Email)}}
|
||||
{{if not $committerIsAuthor}}
|
||||
<div class="flex-text-inline">
|
||||
<span class="tw-text-text-light">{{ctx.Locale.Tr "repo.diff.committed_by"}}</span>
|
||||
{{if and .Verification.CommittingUser .Verification.CommittingUser.ID}}
|
||||
{{if and .Verification.CommittingUser}}
|
||||
{{ctx.AvatarUtils.Avatar .Verification.CommittingUser 20}}
|
||||
<a href="{{.Verification.CommittingUser.HomeLink}}"><strong>{{.Commit.Committer.Name}}</strong></a>
|
||||
<strong>{{.Verification.CommittingUser.GetShortDisplayNameLinkHTML}}</strong>
|
||||
{{else}}
|
||||
{{ctx.AvatarUtils.AvatarByEmail .Commit.Committer.Email .Commit.Committer.Name 20}}
|
||||
{{ctx.AvatarUtils.AvatarByEmail .Commit.Committer.Email .Commit.Committer.Email 20}}
|
||||
<strong>{{.Commit.Committer.Name}}</strong>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .CommitOtherParticipants}}
|
||||
<div class="flex-text-inline">
|
||||
@@ -162,16 +159,12 @@
|
||||
{{$gitIdentity := $participant.GitIdentity}}
|
||||
{{if $user}}
|
||||
{{ctx.AvatarUtils.Avatar $user 20}}
|
||||
<a class="muted" href="{{$user.HomeLink}}"><strong>{{$user.GetDisplayName}}</strong></a>
|
||||
<strong>{{$user.GetShortDisplayNameLinkHTML}}</strong>
|
||||
{{else}}
|
||||
{{$gitName := $gitIdentity.Name}}
|
||||
{{$gitEmail := $gitIdentity.Email}}
|
||||
{{ctx.AvatarUtils.AvatarByEmail $gitEmail $gitName 20}}
|
||||
{{if $gitEmail}}
|
||||
<a class="muted" href="mailto:{{$gitEmail}}"><strong>{{$gitName}}</strong></a>
|
||||
{{else}}
|
||||
<strong>{{$gitName}}</strong>
|
||||
{{end}}
|
||||
{{ctx.AvatarUtils.AvatarByEmail $gitEmail $gitEmail 20}}{{/* use the same layout as the "author" above */}}
|
||||
<strong>{{$gitName}}</strong>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@@ -638,7 +638,7 @@ jobs:
|
||||
return false
|
||||
}
|
||||
if latestCommitStatuses[0].State == commitstatus.CommitStatusPending {
|
||||
insertFakeStatus(t, repo, sha, latestCommitStatuses[0].TargetURL, latestCommitStatuses[0].Context)
|
||||
insertFakeStatus(t, repo, sha, latestCommitStatuses[0])
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -680,14 +680,18 @@ func checkCommitStatusAndInsertFakeStatus(t *testing.T, repo *repo_model.Reposit
|
||||
assert.Len(t, latestCommitStatuses, 1)
|
||||
assert.Equal(t, commitstatus.CommitStatusPending, latestCommitStatuses[0].State)
|
||||
|
||||
insertFakeStatus(t, repo, sha, latestCommitStatuses[0].TargetURL, latestCommitStatuses[0].Context)
|
||||
insertFakeStatus(t, repo, sha, latestCommitStatuses[0])
|
||||
}
|
||||
|
||||
func insertFakeStatus(t *testing.T, repo *repo_model.Repository, sha, targetURL, context string) {
|
||||
// insertFakeStatus inserts a success status that lands in the same dedupe
|
||||
// group as `prev` — the actions runner mixes the workflow file path into
|
||||
// ContextHash, so we must reuse it (rather than recomputing from Context).
|
||||
func insertFakeStatus(t *testing.T, repo *repo_model.Repository, sha string, prev *git_model.CommitStatus) {
|
||||
err := commitstatus_service.CreateCommitStatus(t.Context(), repo, user_model.NewActionsUser(), sha, &git_model.CommitStatus{
|
||||
State: commitstatus.CommitStatusSuccess,
|
||||
TargetURL: targetURL,
|
||||
Context: context,
|
||||
State: commitstatus.CommitStatusSuccess,
|
||||
TargetURL: prev.TargetURL,
|
||||
Context: prev.Context,
|
||||
ContextHash: prev.ContextHash,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
@@ -822,7 +826,7 @@ jobs:
|
||||
return false
|
||||
}
|
||||
if latestCommitStatuses[0].State == commitstatus.CommitStatusPending {
|
||||
insertFakeStatus(t, repo, sha, latestCommitStatuses[0].TargetURL, latestCommitStatuses[0].Context)
|
||||
insertFakeStatus(t, repo, sha, latestCommitStatuses[0])
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -5,6 +5,7 @@ RUN_MODE = prod
|
||||
[database]
|
||||
DB_TYPE = sqlite3
|
||||
PATH = gitea-test.db
|
||||
SQLITE_JOURNAL_MODE = WAL
|
||||
|
||||
[indexer]
|
||||
REPO_INDEXER_ENABLED = true
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
test('createElementFromHTML', () => {
|
||||
expect(createElementFromHTML('<a>foo<span>bar</span></a>').outerHTML).toEqual('<a>foo<span>bar</span></a>');
|
||||
expect(createElementFromHTML('<tr data-x="1"><td>foo</td></tr>').outerHTML).toEqual('<tr data-x="1"><td>foo</td></tr>');
|
||||
expect(createElementFromHTML('<TR data-x="1"><td>foo</td></TR>').outerHTML).toEqual('<tr data-x="1"><td>foo</td></tr>');
|
||||
expect(createElementFromHTML('<trx></trx>').outerHTML).toEqual('<trx></trx>');
|
||||
});
|
||||
|
||||
test('createElementFromAttrs', () => {
|
||||
|
||||
@@ -267,9 +267,14 @@ export function isElemVisible(el: HTMLElement): boolean {
|
||||
|
||||
export function createElementFromHTML<T extends Element>(htmlString: string): T {
|
||||
htmlString = htmlString.trim();
|
||||
const isLetter = (code: number) => (code >= 65 && code <= 90) || (code >= 97 && code <= 122);
|
||||
const startsWithTag = (s: string, tag: string) => {
|
||||
return s.startsWith('<') &&
|
||||
s.substring(1, 1 + tag.length).toLowerCase() === tag.toLowerCase() &&
|
||||
!isLetter(s[1 + tag.length].charCodeAt(0));
|
||||
};
|
||||
// There is no way to create some elements without a proper parent, jQuery's approach: https://github.com/jquery/jquery/blob/main/src/manipulation/wrapMap.js
|
||||
// eslint-disable-next-line github/unescaped-html-literal
|
||||
if (htmlString.startsWith('<tr')) {
|
||||
if (startsWithTag(htmlString, 'tr')) {
|
||||
const container = document.createElement('table');
|
||||
container.innerHTML = htmlString;
|
||||
return container.querySelector<T>('tr')!;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import {html, htmlRaw} from './html.ts';
|
||||
|
||||
export function urlQueryEscape(s: string) {
|
||||
// See "TestQueryEscape" in backend
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_rfc3986
|
||||
@@ -35,9 +37,9 @@ const urlLinkifyPattern = /(<([-\w]+)[^>]*>)|(<\/([-\w]+)[^>]*>)|(https?:\/\/[^\
|
||||
const trailingPunctPattern = /[.,;:!?]+$/;
|
||||
|
||||
// Convert URLs to clickable links in HTML, preserving existing HTML tags
|
||||
export function linkifyURLs(html: string): string {
|
||||
export function linkifyURLs(htmlString: string): string {
|
||||
let inAnchor = false;
|
||||
return html.replace(urlLinkifyPattern, (match, _openTagFull, openTag, _closeTagFull, closeTag, url) => {
|
||||
return htmlString.replace(urlLinkifyPattern, (match, _openTagFull, openTag, _closeTagFull, closeTag, url) => {
|
||||
// skip URLs inside existing <a> tags
|
||||
if (openTag === 'a') {
|
||||
inAnchor = true;
|
||||
@@ -54,6 +56,6 @@ export function linkifyURLs(html: string): string {
|
||||
const cleanUrl = trailingPunct ? url.slice(0, -trailingPunct[0].length) : url;
|
||||
const trailing = trailingPunct ? trailingPunct[0] : '';
|
||||
// safe because regexp only matches valid URLs (no quotes or angle brackets)
|
||||
return `<a href="${cleanUrl}" target="_blank">${cleanUrl}</a>${trailing}`; // eslint-disable-line github/unescaped-html-literal
|
||||
return html`<a href="${htmlRaw(cleanUrl)}" target="_blank">${htmlRaw(cleanUrl)}</a>${htmlRaw(trailing)}`;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user