diff --git a/.github/workflows/cron-renovate.yml b/.github/workflows/cron-renovate.yml index 4db83a336d..843527ba20 100644 --- a/.github/workflows/cron-renovate.yml +++ b/.github/workflows/cron-renovate.yml @@ -21,7 +21,7 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: renovatebot/github-action@693b9ef15eec82123529a37c782242f091365961 # v46.1.14 + - uses: renovatebot/github-action@8217b3fc286df088d7c27f3255fe8414463bc0fd # v46.1.15 with: renovate-version: ${{ env.RENOVATE_VERSION }} configurationFile: renovate.json5 diff --git a/.github/workflows/pull-db-tests.yml b/.github/workflows/pull-db-tests.yml index 4cc8d25bbb..a35937f266 100644 --- a/.github/workflows/pull-db-tests.yml +++ b/.github/workflows/pull-db-tests.yml @@ -131,7 +131,7 @@ jobs: ports: - "7700:7700" redis: - image: redis:latest@sha256:e74c9b933d78e2829583d88f92793f4524752a15ac59c8baff2dd5ed000b7432 + image: redis:latest@sha256:a505f8b9d8ac3ff7b0848055b4abf1901d6d77606774aa1e38bd37f1197ed2b5 options: >- # wait until redis has started --health-cmd "redis-cli ping" --health-interval 5s diff --git a/Dockerfile b/Dockerfile index 383e761330..57cc12dfe9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1 # Build frontend on the native platform to avoid QEMU-related issues with nodejs ecosystem -FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26-alpine3.23 AS frontend-build +FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26-alpine3.24 AS frontend-build RUN apk --no-cache add build-base git nodejs pnpm WORKDIR /src COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ @@ -9,7 +9,7 @@ COPY --exclude=.git/ . . RUN make frontend # Build backend for each target platform -FROM docker.io/library/golang:1.26-alpine3.23 AS build-env +FROM docker.io/library/golang:1.26-alpine3.24 AS build-env ARG GITEA_VERSION ARG TAGS="" @@ -44,7 +44,7 @@ RUN chmod 755 /tmp/local/usr/bin/entrypoint \ /tmp/local/etc/s6/.s6-svscan/* \ /go/src/gitea.dev/gitea -FROM docker.io/library/alpine:3.23 AS gitea +FROM docker.io/library/alpine:3.24 AS gitea EXPOSE 22 3000 diff --git a/Dockerfile.rootless b/Dockerfile.rootless index af1ef336ae..4be5a4f8b3 100644 --- a/Dockerfile.rootless +++ b/Dockerfile.rootless @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1 # Build frontend on the native platform to avoid QEMU-related issues with nodejs ecosystem -FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26-alpine3.23 AS frontend-build +FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26-alpine3.24 AS frontend-build RUN apk --no-cache add build-base git nodejs pnpm WORKDIR /src COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ @@ -9,7 +9,7 @@ COPY --exclude=.git/ . . RUN make frontend # Build backend for each target platform -FROM docker.io/library/golang:1.26-alpine3.23 AS build-env +FROM docker.io/library/golang:1.26-alpine3.24 AS build-env ARG GITEA_VERSION ARG TAGS="" @@ -39,7 +39,7 @@ COPY docker/rootless /tmp/local RUN chmod 755 /tmp/local/usr/local/bin/* \ /go/src/gitea.dev/gitea -FROM docker.io/library/alpine:3.23 AS gitea-rootless +FROM docker.io/library/alpine:3.24 AS gitea-rootless EXPOSE 2222 3000 diff --git a/build/generate-openapi.go b/build/generate-openapi.go index 3c65781481..f0cdd86634 100644 --- a/build/generate-openapi.go +++ b/build/generate-openapi.go @@ -57,8 +57,8 @@ func main() { log.Fatalf("scanning swagger:enum annotations: %v", err) } names := make([]string, 0, len(astEnumMap)) - for _, n := range astEnumMap { - names = append(names, n) + for _, ns := range astEnumMap { + names = append(names, ns...) } sort.Strings(names) fmt.Fprintf(os.Stderr, "discovered %d swagger:enum types: %s\n", len(names), strings.Join(names, ", ")) diff --git a/build/openapi3gen/convert.go b/build/openapi3gen/convert.go index 9d446ff45b..d1ee1a3a68 100644 --- a/build/openapi3gen/convert.go +++ b/build/openapi3gen/convert.go @@ -6,6 +6,7 @@ package openapi3gen import ( "fmt" "regexp" + "sort" "strings" "gitea.dev/modules/json" @@ -25,10 +26,12 @@ var rxDeprecated = regexp.MustCompile(`(?i)(?:^|[\n.;])\s*deprecated\b`) // Gitea-specific post-processing: file-schema fixups, URI formats, // deprecated flags, and shared-enum extraction. // -// astEnumMap is a value-set-key → Go-type-name map (built by -// ScanSwaggerEnumTypes). If a shared enum in the spec has no entry in the -// map, Convert returns an error — no fallback naming. -func Convert(swaggerJSON []byte, astEnumMap map[string]string) (*openapi3.T, error) { +// astEnumMap is a value-set-key → Go-type-name(s) map (built by +// ScanSwaggerEnumTypes). When a value set is shared by multiple Go types, +// per-property disambiguation uses the x-go-enum-desc extension. If a shared +// enum in the spec has no matching entry, Convert returns an error — no +// fallback naming. +func Convert(swaggerJSON []byte, astEnumMap map[string][]string) (*openapi3.T, error) { var swagger2 openapi2.T if err := json.Unmarshal(swaggerJSON, &swagger2); err != nil { return nil, fmt.Errorf("parsing swagger 2.0: %w", err) @@ -176,12 +179,24 @@ type enumUsage struct { // If the derived enum name collides with an existing component schema, or // no // swagger:enum annotation matches the value set, generation aborts // with an actionable error — there are no silent fallbacks. -func extractSharedEnums(doc *openapi3.T, astEnumMap map[string]string) error { +func extractSharedEnums(doc *openapi3.T, astEnumMap map[string][]string) error { if doc.Components == nil { return nil } - enumGroups := map[string][]enumUsage{} + type groupKey struct { + valueSet string + typeName string + } + enumGroups := map[groupKey][]enumUsage{} + groupOrder := []groupKey{} // deterministic iteration + + addUsage := func(key groupKey, u enumUsage) { + if _, seen := enumGroups[key]; !seen { + groupOrder = append(groupOrder, key) + } + enumGroups[key] = append(enumGroups[key], u) + } for schemaName, schemaRef := range doc.Components.Schemas { if schemaRef.Value == nil { @@ -192,24 +207,31 @@ func extractSharedEnums(doc *openapi3.T, astEnumMap map[string]string) error { continue } if len(propRef.Value.Enum) > 1 && propRef.Value.Type.Is("string") { - key := EnumKey(propRef.Value.Enum) - enumGroups[key] = append(enumGroups[key], enumUsage{schemaName, propName, propRef, false}) + key := groupKey{ + valueSet: EnumKey(propRef.Value.Enum), + typeName: extractEnumTypeName(propRef.Value, astEnumMap), + } + addUsage(key, enumUsage{schemaName, propName, propRef, false}) } if propRef.Value.Type.Is("array") && propRef.Value.Items != nil && propRef.Value.Items.Value != nil && propRef.Value.Items.Ref == "" && len(propRef.Value.Items.Value.Enum) > 1 && propRef.Value.Items.Value.Type.Is("string") { - key := EnumKey(propRef.Value.Items.Value.Enum) - enumGroups[key] = append(enumGroups[key], enumUsage{schemaName, propName, propRef, true}) + key := groupKey{ + valueSet: EnumKey(propRef.Value.Items.Value.Enum), + typeName: extractEnumTypeName(propRef.Value.Items.Value, astEnumMap), + } + addUsage(key, enumUsage{schemaName, propName, propRef, true}) } } } - for key, usages := range enumGroups { + for _, key := range groupOrder { + usages := enumGroups[key] if len(usages) < 2 { continue } - enumName, err := deriveEnumName(key, usages, astEnumMap) + enumName, err := deriveEnumName(key.valueSet, key.typeName, usages, astEnumMap) if err != nil { return err } @@ -257,12 +279,17 @@ func extractSharedEnums(doc *openapi3.T, astEnumMap map[string]string) error { return nil } -// deriveEnumName looks up a shared enum's Go type name from astEnumMap by -// value-set key. If no annotation matches, returns an error identifying the -// offending properties and the fix. -func deriveEnumName(key string, usages []enumUsage, astEnumMap map[string]string) (string, error) { - if name, ok := astEnumMap[key]; ok { - return name, nil +// deriveEnumName looks up a shared enum's Go type name. If typeName is +// non-empty (because we recovered it from x-go-enum-desc), it is used +// directly. Otherwise the value-set must map to exactly one known type. On +// failure, returns an error identifying the offending properties. +func deriveEnumName(key, typeName string, usages []enumUsage, astEnumMap map[string][]string) (string, error) { + if typeName != "" { + return typeName, nil + } + names := astEnumMap[key] + if len(names) == 1 { + return names[0], nil } props := map[string]bool{} @@ -273,9 +300,87 @@ func deriveEnumName(key string, usages []enumUsage, astEnumMap map[string]string for p := range props { propList = append(propList, p) } + if len(names) > 1 { + return "", fmt.Errorf( + "value-set %q is shared by multiple swagger:enum types %v and could not be disambiguated for properties: %v; "+ + "ensure go-swagger emits x-go-enum-desc for those properties", + key, names, propList, + ) + } return "", fmt.Errorf( "no swagger:enum annotation matches value-set %q used by %d properties: %v; "+ "fix by adding a named string type with // swagger:enum to modules/structs or modules/commitstatus", key, len(usages), propList, ) } + +// extractEnumTypeName recovers the Go type name a schema's enum came from by +// parsing the property's x-go-enum-desc extension. go-swagger emits one line +// per value as " [ ]"; the type is the longest +// common prefix of the const names, narrowed to the candidate set in +// astEnumMap. Returns "" if extraction is inconclusive. +func extractEnumTypeName(s *openapi3.Schema, astEnumMap map[string][]string) string { + if s == nil || s.Extensions == nil { + return "" + } + raw, ok := s.Extensions["x-go-enum-desc"] + if !ok { + return "" + } + desc, ok := raw.(string) + if !ok { + return "" + } + candidates := astEnumMap[EnumKey(s.Enum)] + if len(candidates) == 0 { + return "" + } + // Collect the const names (second whitespace-separated field per line). + var consts []string + for line := range strings.SplitSeq(desc, "\n") { + fields := strings.Fields(line) + if len(fields) >= 2 { + consts = append(consts, fields[1]) + } + } + if len(consts) == 0 { + return "" + } + // A candidate matches when it is a prefix of every const name AND the + // first character after the prefix is an uppercase ASCII letter — this + // rejects e.g. "Alpha" matching "Alphabet" (suffix "bet" starts lower) + // while still accepting both "Alpha" and "AlphaPlus" against "AlphaPlusX" + // (both prefixes valid). The most specific (longest) wins; ties return + // "" so deriveEnumName surfaces the ambiguity rather than silently + // picking a winner. + ordered := append([]string(nil), candidates...) + sort.Slice(ordered, func(i, j int) bool { return len(ordered[i]) > len(ordered[j]) }) + var matches []string + for _, name := range ordered { + ok := true + for _, c := range consts { + if !strings.HasPrefix(c, name) { + ok = false + break + } + suffix := c[len(name):] + // Empty suffix means the const name exactly equals the type name — valid exact match. + // A non-empty suffix must begin with an uppercase letter to reject incidental + // prefix matches (e.g. "Alpha" should not match "Alphabet"). + if len(suffix) > 0 && (suffix[0] < 'A' || suffix[0] > 'Z') { + ok = false + break + } + } + if ok { + matches = append(matches, name) + } + } + if len(matches) == 0 { + return "" + } + if len(matches) > 1 && len(matches[0]) == len(matches[1]) { + return "" + } + return matches[0] +} diff --git a/build/openapi3gen/convert_test.go b/build/openapi3gen/convert_test.go index a9a715e6c2..1cf73bd1f0 100644 --- a/build/openapi3gen/convert_test.go +++ b/build/openapi3gen/convert_test.go @@ -12,9 +12,9 @@ import ( func TestDeriveEnumName_hit(t *testing.T) { key := EnumKey([]any{"red", "green", "blue"}) - astMap := map[string]string{key: "Color"} + astMap := map[string][]string{key: {"Color"}} usages := []enumUsage{{schemaName: "Paint", propName: "color"}} - got, err := deriveEnumName(key, usages, astMap) + got, err := deriveEnumName(key, "", usages, astMap) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -26,7 +26,7 @@ func TestDeriveEnumName_hit(t *testing.T) { func TestDeriveEnumName_miss(t *testing.T) { key := EnumKey([]any{"x", "y"}) usages := []enumUsage{{schemaName: "Thing", propName: "kind"}} - _, err := deriveEnumName(key, usages, map[string]string{}) + _, err := deriveEnumName(key, "", usages, map[string][]string{}) if err == nil { t.Fatal("expected miss error, got nil") } @@ -64,7 +64,7 @@ func TestExtractSharedEnums_usesASTMap(t *testing.T) { }, }, } - astMap := map[string]string{EnumKey([]any{"red", "green", "blue"}): "Color"} + astMap := map[string][]string{EnumKey([]any{"red", "green", "blue"}): {"Color"}} if err := extractSharedEnums(doc, astMap); err != nil { t.Fatalf("extractSharedEnums: %v", err) } @@ -139,6 +139,54 @@ func TestFixFileSchemas_recursesIntoNested(t *testing.T) { } } +func TestExtractEnumTypeName_TeamVisibility(t *testing.T) { + enum := []any{"public", "limited", "private"} + key := EnumKey(enum) + astMap := map[string][]string{key: {"UserVisibility", "TeamVisibility"}} + schema := &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Enum: enum, + Extensions: map[string]any{ + "x-go-enum-desc": "public TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate", + }, + } + if got := extractEnumTypeName(schema, astMap); got != "TeamVisibility" { + t.Fatalf("got %q, want %q", got, "TeamVisibility") + } +} + +func TestExtractEnumTypeName_ambiguousPrefixTie(t *testing.T) { + enum := []any{"one", "two"} + key := EnumKey(enum) + astMap := map[string][]string{key: {"AB", "AC"}} + schema := &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Enum: enum, + Extensions: map[string]any{ + "x-go-enum-desc": "one ABOne\ntwo ACTwo", + }, + } + if got := extractEnumTypeName(schema, astMap); got != "" { + t.Fatalf("got %q, want empty string for ambiguous tie", got) + } +} + +func TestExtractEnumTypeName_rejectsIncidentalPrefix(t *testing.T) { + enum := []any{"a", "b"} + key := EnumKey(enum) + astMap := map[string][]string{key: {"Alpha", "Alphabet"}} + schema := &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Enum: enum, + Extensions: map[string]any{ + "x-go-enum-desc": "a AlphabetA\nb AlphabetB", + }, + } + if got := extractEnumTypeName(schema, astMap); got != "Alphabet" { + t.Fatalf("got %q, want %q", got, "Alphabet") + } +} + func TestExtractSharedEnums_missReturnsError(t *testing.T) { doc := &openapi3.T{ Components: &openapi3.Components{ @@ -164,7 +212,7 @@ func TestExtractSharedEnums_missReturnsError(t *testing.T) { }, }, } - if err := extractSharedEnums(doc, map[string]string{}); err == nil { + if err := extractSharedEnums(doc, map[string][]string{}); err == nil { t.Fatal("expected miss error") } } diff --git a/build/openapi3gen/enumscan.go b/build/openapi3gen/enumscan.go index dd11620549..73bbcb5e15 100644 --- a/build/openapi3gen/enumscan.go +++ b/build/openapi3gen/enumscan.go @@ -34,13 +34,16 @@ func EnumKey(values []any) string { var rxSwaggerEnum = regexp.MustCompile(`swagger:enum\s+(\w+)`) // ScanSwaggerEnumTypes walks .go files under each dir and returns a map from -// a canonical value-set key (see EnumKey) to the Go type name declared with -// // swagger:enum TypeName. +// a canonical value-set key (see EnumKey) to the Go type names declared with +// // swagger:enum TypeName. Multiple type names per key are allowed (e.g. +// distinct enum types that happen to share a value set such as +// {public, limited, private}); callers must disambiguate per-usage (typically +// by parsing the property's x-go-enum-desc extension to recover the const +// type prefix). // -// Returns an error on parse failure, on an annotation for a type whose -// constants can't be extracted, or on value-set collisions between two -// different enum types. -func ScanSwaggerEnumTypes(dirs []string) (map[string]string, error) { +// Returns an error on parse failure or on an annotation for a type whose +// constants can't be extracted. +func ScanSwaggerEnumTypes(dirs []string) (map[string][]string, error) { fset := token.NewFileSet() parsed := []*ast.File{} @@ -92,17 +95,18 @@ func ScanSwaggerEnumTypes(dirs []string) (map[string]string, error) { } } - result := map[string]string{} + result := map[string][]string{} for typeName := range enumTypes { values, ok := enumValues[typeName] if !ok || len(values) == 0 { return nil, fmt.Errorf("swagger:enum %s has no const block with typed string values", typeName) } key := EnumKey(values) - if existing, ok := result[key]; ok && existing != typeName { - return nil, fmt.Errorf("swagger:enum value-set collision: %s and %s both use %q", existing, typeName, key) - } - result[key] = typeName + result[key] = append(result[key], typeName) + } + for key, names := range result { + sort.Strings(names) + result[key] = names } return result, nil } diff --git a/build/openapi3gen/enumscan_test.go b/build/openapi3gen/enumscan_test.go index 2e5fe99db0..8cb16b721d 100644 --- a/build/openapi3gen/enumscan_test.go +++ b/build/openapi3gen/enumscan_test.go @@ -6,10 +6,19 @@ package openapi3gen import ( "os" "path/filepath" + "slices" "strings" "testing" ) +func single(got map[string][]string, key string) string { + v := got[key] + if len(v) != 1 { + return "" + } + return v[0] +} + func TestEnumKey_sortsAndJoins(t *testing.T) { key := EnumKey([]any{"b", "a", "c"}) if key != "a|b|c" { @@ -47,7 +56,7 @@ const ( t.Fatalf("ScanSwaggerEnumTypes: %v", err) } wantKey := EnumKey([]any{"red", "green", "blue"}) - if got[wantKey] != "Color" { + if single(got, wantKey) != "Color" { t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Color") } } @@ -98,13 +107,14 @@ const ( t.Fatal(err) } - _, err := ScanSwaggerEnumTypes([]string{dir}) - if err == nil { - t.Fatal("expected collision error, got nil") + got, err := ScanSwaggerEnumTypes([]string{dir}) + if err != nil { + t.Fatalf("ScanSwaggerEnumTypes: %v", err) } - msg := err.Error() - if !strings.Contains(msg, "Alpha") || !strings.Contains(msg, "Beta") { - t.Fatalf("error %q should mention both Alpha and Beta", msg) + key := EnumKey([]any{"x", "y"}) + names := got[key] + if !slices.Equal(names, []string{"Alpha", "Beta"}) { + t.Fatalf("map[%q] = %v, want [Alpha Beta]", key, names) } } @@ -168,7 +178,7 @@ type Hue string t.Fatalf("ScanSwaggerEnumTypes: %v", err) } wantKey := EnumKey([]any{"a", "b"}) - if got[wantKey] != "Hue" { + if single(got, wantKey) != "Hue" { t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Hue") } } @@ -194,7 +204,7 @@ type Shade string t.Fatalf("ScanSwaggerEnumTypes: %v", err) } wantKey := EnumKey([]any{"dark", "light"}) - if got[wantKey] != "Shade" { + if single(got, wantKey) != "Shade" { t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Shade") } } @@ -230,10 +240,10 @@ const ( } colorKey := EnumKey([]any{"red", "blue"}) shadeKey := EnumKey([]any{"dark", "light"}) - if got[colorKey] != "Color" { + if single(got, colorKey) != "Color" { t.Fatalf("Color: map[%q] = %q, want %q", colorKey, got[colorKey], "Color") } - if got[shadeKey] != "Shade" { + if single(got, shadeKey) != "Shade" { t.Fatalf("Shade: map[%q] = %q, want %q", shadeKey, got[shadeKey], "Shade") } } diff --git a/docs/community-governance.md b/docs/community-governance.md index 0d9a9835a7..b721a2e8bd 100644 --- a/docs/community-governance.md +++ b/docs/community-governance.md @@ -164,7 +164,12 @@ Mergers are the maintainers who carry out the final merge of approved PRs. Their #### Becoming a merger -A merger should already be a Gitea maintainer. To apply, use the [Discord](https://discord.gg/Gitea) `#maintainers` channel. Mergers teams may also invite contributors. +A merger must already be a Gitea maintainer. +To apply, use the [Discord](https://discord.gg/Gitea) `#maintainers` channel. +The minimum requirement for applications to become a merger is to have participated actively in the community for at least four months before applying. +Ultimately, regardless of previous participation, you can only become a merger if the TOC votes in your favor. + +You may also be invited by the TOC to become a merger. ### Technical Oversight Committee (TOC) @@ -185,17 +190,30 @@ As long as seats are empty in the TOC, members of the previous TOC can fill them If an elected member that accepts the seat does not have 2FA configured yet, they will be temporarily counted as `answer pending` until they manage to configure 2FA, thus leaving their seat empty for this duration. +If multiple persons have the same amount of votes, a random draw will be used to determine the order of the candidates with the same amount of votes, and thus who gets the seat first. +The candidates will be placed in the list in an alphabetical insensitive order by their username. +We use this script to determine the order of candidates with the same amount of votes: + +```python +import random +random.seed("Gitea TOC Election") +random.choice([, , ...]) +``` + +The result of this script needs then to be published in the TOC election issue to ensure transparency of the process. + ### Current TOC members -- 2025-01-01 ~ 2026-06-14 +- 2026-06-14 ~ 2026-12-31 - Company - [Jason Song](https://gitea.com/wolfogre) - [Lunny Xiao](https://gitea.com/lunny) - [Matti Ranta](https://gitea.com/techknowlogick) - Community - - [6543](https://gitea.com/6543) <6543@obermui.de> + - [bircni](https://gitea.com/bircni) - [delvh](https://gitea.com/delvh) - - [lafriks](https://gitea.com/lafriks) + - [TheFox0x7](https://gitea.com/TheFox0x7) + ### Previous TOC/owners members @@ -204,10 +222,10 @@ Here's the history of the owners and the time they served: - [Lunny Xiao](https://gitea.com/lunny) - 2016, 2017, [2018](https://github.com/go-gitea/gitea/issues/3255), [2019](https://github.com/go-gitea/gitea/issues/5572), [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801), [2022](https://github.com/go-gitea/gitea/issues/17872), 2023 - [Kim Carlbäcker](https://github.com/bkcsoft) - 2016, 2017 - [Thomas Boerger](https://gitea.com/tboerger) - 2016, 2017 -- [Lauris Bukšis-Haberkorns](https://gitea.com/lafriks) - [2018](https://github.com/go-gitea/gitea/issues/3255), [2019](https://github.com/go-gitea/gitea/issues/5572), [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801) +- [Lauris Bukšis](https://gitea.com/lafriks) - [2018](https://github.com/go-gitea/gitea/issues/3255), [2019](https://github.com/go-gitea/gitea/issues/5572), [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801), 2025 - [Matti Ranta](https://gitea.com/techknowlogick) - [2019](https://github.com/go-gitea/gitea/issues/5572), [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801), [2022](https://github.com/go-gitea/gitea/issues/17872), 2023 - [Andrew Thornton](https://gitea.com/zeripath) - [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801), [2022](https://github.com/go-gitea/gitea/issues/17872), 2023 -- [6543](https://gitea.com/6543) - 2023 +- [6543](https://gitea.com/6543) - 2023, 2025 - [John Olheiser](https://gitea.com/jolheiser) - 2023, 2024 - [Jason Song](https://gitea.com/wolfogre) - 2023 diff --git a/main.go b/main.go index 80b8a51d4a..0a3d0164fa 100644 --- a/main.go +++ b/main.go @@ -17,6 +17,7 @@ import ( // register supported doc types _ "gitea.dev/modules/markup/console" _ "gitea.dev/modules/markup/csv" + _ "gitea.dev/modules/markup/jupyter" _ "gitea.dev/modules/markup/markdown" _ "gitea.dev/modules/markup/orgmode" diff --git a/models/actions/run_list.go b/models/actions/run_list.go index 553fd7bae9..88f3d3dd82 100644 --- a/models/actions/run_list.go +++ b/models/actions/run_list.go @@ -111,6 +111,7 @@ func (opts FindRunOptions) ToOrders() string { type StatusInfo struct { Status int + StatusName string DisplayedStatus string } @@ -122,6 +123,7 @@ func GetStatusInfoList(ctx context.Context, lang translation.Locale) []StatusInf for _, s := range allStatus { statusInfoList = append(statusInfoList, StatusInfo{ Status: int(s), + StatusName: s.String(), DisplayedStatus: s.LocaleString(lang), }) } diff --git a/models/actions/run_list_test.go b/models/actions/run_list_test.go index 70d8dd91b1..74f630bb55 100644 --- a/models/actions/run_list_test.go +++ b/models/actions/run_list_test.go @@ -7,6 +7,7 @@ import ( "testing" "gitea.dev/models/unittest" + "gitea.dev/modules/translation" "github.com/stretchr/testify/assert" ) @@ -22,3 +23,15 @@ func TestGetRunWorkflowIDs(t *testing.T) { assert.NoError(t, err) assert.Empty(t, ids) } + +func TestGetStatusInfoList(t *testing.T) { + statusInfoList := GetStatusInfoList(t.Context(), translation.MockLocale{}) + + assert.Equal(t, []StatusInfo{ + {Status: int(StatusSuccess), StatusName: StatusSuccess.String(), DisplayedStatus: "actions.status.success"}, + {Status: int(StatusFailure), StatusName: StatusFailure.String(), DisplayedStatus: "actions.status.failure"}, + {Status: int(StatusWaiting), StatusName: StatusWaiting.String(), DisplayedStatus: "actions.status.waiting"}, + {Status: int(StatusRunning), StatusName: StatusRunning.String(), DisplayedStatus: "actions.status.running"}, + {Status: int(StatusCancelling), StatusName: StatusCancelling.String(), DisplayedStatus: "actions.status.cancelling"}, + }, statusInfoList) +} diff --git a/models/auth/access_token.go b/models/auth/access_token.go index da451fb044..63a345dfcd 100644 --- a/models/auth/access_token.go +++ b/models/auth/access_token.go @@ -20,42 +20,6 @@ import ( "xorm.io/builder" ) -// ErrAccessTokenNotExist represents a "AccessTokenNotExist" kind of error. -type ErrAccessTokenNotExist struct { - Token string -} - -// IsErrAccessTokenNotExist checks if an error is a ErrAccessTokenNotExist. -func IsErrAccessTokenNotExist(err error) bool { - _, ok := err.(ErrAccessTokenNotExist) - return ok -} - -func (err ErrAccessTokenNotExist) Error() string { - return fmt.Sprintf("access token does not exist [sha: %s]", err.Token) -} - -func (err ErrAccessTokenNotExist) Unwrap() error { - return util.ErrNotExist -} - -// ErrAccessTokenEmpty represents a "AccessTokenEmpty" kind of error. -type ErrAccessTokenEmpty struct{} - -// IsErrAccessTokenEmpty checks if an error is a ErrAccessTokenEmpty. -func IsErrAccessTokenEmpty(err error) bool { - _, ok := err.(ErrAccessTokenEmpty) - return ok -} - -func (err ErrAccessTokenEmpty) Error() string { - return "access token is empty" -} - -func (err ErrAccessTokenEmpty) Unwrap() error { - return util.ErrInvalidArgument -} - var successfulAccessTokenCache *lru.Cache[string, any] // AccessToken represents a personal access token. @@ -134,21 +98,11 @@ func getAccessTokenIDFromCache(token string) int64 { // GetAccessTokenBySHA returns access token by given token value func GetAccessTokenBySHA(ctx context.Context, token string) (*AccessToken, error) { - if token == "" { - return nil, ErrAccessTokenEmpty{} - } - // A token is defined as being SHA1 sum these are 40 hexadecimal bytes long - if len(token) != 40 { - return nil, ErrAccessTokenNotExist{token} - } - for _, x := range []byte(token) { - if x < '0' || (x > '9' && x < 'a') || x > 'f' { - return nil, ErrAccessTokenNotExist{token} - } + if len(token) < 8 { + return nil, util.NewNotExistErrorf("access token not found") } lastEight := token[len(token)-8:] - if id := getAccessTokenIDFromCache(token); id > 0 { accessToken := &AccessToken{ TokenLastEight: lastEight, @@ -169,7 +123,7 @@ func GetAccessTokenBySHA(ctx context.Context, token string) (*AccessToken, error if err != nil { return nil, err } else if len(tokens) == 0 { - return nil, ErrAccessTokenNotExist{token} + return nil, util.NewNotExistErrorf("access token not found") } for _, t := range tokens { @@ -181,7 +135,7 @@ func GetAccessTokenBySHA(ctx context.Context, token string) (*AccessToken, error return &t, nil } } - return nil, ErrAccessTokenNotExist{token} + return nil, util.NewNotExistErrorf("access token not found") } // AccessTokenByNameExists checks if a token name has been used already by a user. @@ -218,13 +172,11 @@ func UpdateAccessToken(ctx context.Context, t *AccessToken) error { // DeleteAccessTokenByID deletes access token by given ID. func DeleteAccessTokenByID(ctx context.Context, id, userID int64) error { - cnt, err := db.GetEngine(ctx).ID(id).Delete(&AccessToken{ - UID: userID, - }) + cnt, err := db.GetEngine(ctx).ID(id).Delete(&AccessToken{UID: userID}) if err != nil { return err } else if cnt != 1 { - return ErrAccessTokenNotExist{} + return util.NewNotExistErrorf("access token not found") } return nil } diff --git a/models/auth/access_token_test.go b/models/auth/access_token_test.go index 504600cd08..acab8b3ab5 100644 --- a/models/auth/access_token_test.go +++ b/models/auth/access_token_test.go @@ -9,6 +9,7 @@ import ( auth_model "gitea.dev/models/auth" "gitea.dev/models/db" "gitea.dev/models/unittest" + "gitea.dev/modules/util" "github.com/stretchr/testify/assert" ) @@ -76,11 +77,11 @@ func TestGetAccessTokenBySHA(t *testing.T) { _, err = auth_model.GetAccessTokenBySHA(t.Context(), "notahash") assert.Error(t, err) - assert.True(t, auth_model.IsErrAccessTokenNotExist(err)) + assert.ErrorIs(t, err, util.ErrNotExist) _, err = auth_model.GetAccessTokenBySHA(t.Context(), "") assert.Error(t, err) - assert.True(t, auth_model.IsErrAccessTokenEmpty(err)) + assert.ErrorIs(t, err, util.ErrNotExist) } func TestListAccessTokens(t *testing.T) { @@ -128,5 +129,5 @@ func TestDeleteAccessTokenByID(t *testing.T) { err = auth_model.DeleteAccessTokenByID(t.Context(), 100, 100) assert.Error(t, err) - assert.True(t, auth_model.IsErrAccessTokenNotExist(err)) + assert.ErrorIs(t, err, util.ErrNotExist) } diff --git a/models/db/engine.go b/models/db/engine.go index d4ac0b4aca..ef0636ca60 100755 --- a/models/db/engine.go +++ b/models/db/engine.go @@ -28,9 +28,8 @@ var ( registeredInitFuncs []func() error ) -// Engine represents a xorm engine or session. -type Engine interface { - Table(tableNameOrBean any) *xorm.Session +// SQLSession represents a common interface for engine and session to execute SQLs +type SQLSession interface { Count(...any) (int64, error) Decr(column string, arg ...any) *xorm.Session Delete(...any) (int64, error) @@ -52,7 +51,6 @@ type Engine interface { Limit(limit int, start ...int) *xorm.Session NoAutoTime() *xorm.Session SumInt(bean any, columnName string) (res int64, err error) - Sync(...any) error Select(string) *xorm.Session SetExpr(string, any) *xorm.Session NotIn(string, ...any) *xorm.Session @@ -61,12 +59,20 @@ type Engine interface { Distinct(...string) *xorm.Session Query(...any) ([]map[string][]byte, error) Cols(...string) *xorm.Session + Table(tableNameOrBean any) *xorm.Session Context(ctx context.Context) *xorm.Session - Ping() error + QueryInterface(sqlOrArgs ...any) ([]map[string]any, error) IsTableExist(tableNameOrBean any) (bool, error) } -// Session represents a xorm session interface, used as an abstraction over *xorm.Session. +// Engine represents a xorm engine +type Engine interface { + SQLSession + Sync(...any) error + Ping() error +} + +// Session represents a xorm session interface type Session interface { Engine And(query any, args ...any) *xorm.Session @@ -89,7 +95,6 @@ type EngineMigration interface { Dialect() dialects.Dialect DropTables(beans ...any) error NewSession() *xorm.Session - QueryInterface(sqlOrArgs ...any) ([]map[string]any, error) SetMapper(mapper names.Mapper) SyncWithOptions(opts xorm.SyncOptions, beans ...any) (*xorm.SyncResult, error) TableInfo(bean any) (*schemas.Table, error) diff --git a/models/db/list.go b/models/db/list.go index 47c163c1d7..e91a705411 100644 --- a/models/db/list.go +++ b/models/db/list.go @@ -24,10 +24,9 @@ type Paginator interface { } // SetSessionPagination sets pagination for a database session -func SetSessionPagination(sess Engine, p Paginator) Session { +func SetSessionPagination(sess Engine, p Paginator) { skip, take := p.GetSkipTake() - - return sess.Limit(take, skip) + sess.Limit(take, skip) } // ListOptions options to paginate results diff --git a/models/issues/pull_list.go b/models/issues/pull_list.go index f271850a14..b734c81af9 100644 --- a/models/issues/pull_list.go +++ b/models/issues/pull_list.go @@ -181,7 +181,7 @@ func PullRequests(ctx context.Context, baseRepoID int64, opts *PullRequestsOptio findSession := listPullRequestStatement(ctx, baseRepoID, opts) applySorts(findSession, opts.SortType, 0) - findSession = db.SetSessionPagination(findSession, opts) + db.SetSessionPagination(findSession, opts) prs := make([]*PullRequest, 0, opts.PageSize) found := findSession.Find(&prs) return prs, maxResults, found diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index aedd679c57..81a618a207 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -414,6 +414,7 @@ func prepareMigrationTasks() []*migration { newMigration(334, "Add cancelling support to action runners", v1_27.AddCancellingSupportToActionRunner), newMigration(335, "Add reusable workflow fields and action_run_attempt_job_id_index table for ActionRunJob", v1_27.AddReusableWorkflowFieldsToActionRunJob), newMigration(336, "Add ActionRunJobSummary table", v1_27.AddActionRunJobSummaryTable), + newMigration(337, "Add visibility to team", v1_27.AddVisibilityToTeam), } return preparedMigrations } diff --git a/models/migrations/v1_27/v337.go b/models/migrations/v1_27/v337.go new file mode 100644 index 0000000000..61fd2445cc --- /dev/null +++ b/models/migrations/v1_27/v337.go @@ -0,0 +1,36 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_27 + +import ( + "gitea.dev/models/db" + + "xorm.io/xorm" +) + +type VisibleType int + +type teamWithVisibility struct { + Visibility VisibleType `xorm:"NOT NULL DEFAULT 2"` +} + +func (teamWithVisibility) TableName() string { + return "team" +} + +func AddVisibilityToTeam(x db.EngineMigration) error { + if _, err := x.SyncWithOptions(xorm.SyncOptions{ + IgnoreDropIndices: true, + IgnoreConstrains: true, + }, new(teamWithVisibility)); err != nil { + return err + } + + // Owner teams must remain listable to all org members; new orgs create + // them as "limited", so make existing owner teams limited too. + // Filter on authorize=4 (AccessModeOwner) so a user-created team that + // happens to share the name "owners" is not accidentally affected. + _, err := x.Exec("UPDATE `team` SET visibility = ? WHERE lower_name = ? AND authorize = ?", 1, "owners", 4) + return err +} diff --git a/models/organization/org.go b/models/organization/org.go index c2f77a83dd..9a28aa3918 100644 --- a/models/organization/org.go +++ b/models/organization/org.go @@ -370,6 +370,7 @@ func CreateOrganization(ctx context.Context, org *Organization, owner *user_mode NumMembers: 1, IncludesAllRepositories: true, CanCreateOrgRepo: true, + Visibility: structs.VisibleTypeLimited, } if err = db.Insert(ctx, t); err != nil { return fmt.Errorf("insert owner team: %w", err) diff --git a/models/organization/team.go b/models/organization/team.go index fd6365e731..ea2f52a1ed 100644 --- a/models/organization/team.go +++ b/models/organization/team.go @@ -14,6 +14,7 @@ import ( "gitea.dev/models/unit" user_model "gitea.dev/models/user" "gitea.dev/modules/log" + "gitea.dev/modules/structs" "gitea.dev/modules/util" "xorm.io/builder" @@ -81,9 +82,36 @@ type Team struct { Members []*user_model.User `xorm:"-"` NumRepos int NumMembers int - Units []*TeamUnit `xorm:"-"` - IncludesAllRepositories bool `xorm:"NOT NULL DEFAULT false"` - CanCreateOrgRepo bool `xorm:"NOT NULL DEFAULT false"` + Units []*TeamUnit `xorm:"-"` + IncludesAllRepositories bool `xorm:"NOT NULL DEFAULT false"` + CanCreateOrgRepo bool `xorm:"NOT NULL DEFAULT false"` + Visibility structs.VisibleType `xorm:"NOT NULL DEFAULT 2"` +} + +func (t *Team) IsPublic() bool { return t.Visibility.IsPublic() } +func (t *Team) IsLimited() bool { return t.Visibility.IsLimited() } +func (t *Team) IsPrivate() bool { return t.Visibility.IsPrivate() } + +// CanNonMemberReadMeta reports whether a non-member, non-owner doer may read +// the team's metadata, based on the team's visibility tier and the parent org's +// visibility. Privileged callers (site admins, org owners, team members) are +// decided by the caller before reaching here. +func (t *Team) CanNonMemberReadMeta(ctx context.Context, org, doer *user_model.User) (bool, error) { + switch t.Visibility { + case structs.VisibleTypePublic: + return HasOrgOrUserVisible(ctx, org, doer), nil + case structs.VisibleTypeLimited: + return IsOrganizationMember(ctx, t.OrgID, doer.ID) + default: + return false, nil + } +} + +func NormalizeTeamVisibility(s string) structs.VisibleType { + if vt, ok := structs.VisibilityModes[s]; ok { + return vt + } + return structs.VisibleTypePrivate } func init() { diff --git a/models/organization/team_list.go b/models/organization/team_list.go index 64b084eaaa..bb2f9f224d 100644 --- a/models/organization/team_list.go +++ b/models/organization/team_list.go @@ -10,6 +10,8 @@ import ( "gitea.dev/models/db" "gitea.dev/models/perm" "gitea.dev/models/unit" + user_model "gitea.dev/models/user" + "gitea.dev/modules/structs" "xorm.io/builder" ) @@ -50,9 +52,15 @@ type SearchTeamOptions struct { Keyword string OrgID int64 IncludeDesc bool + // IncludeVisibilities, when combined with UserID, also returns teams whose + // visibility is in this list, even if UserID is not a member. Typical values: + // - {limited,public} for org members + // - {public} for signed-in users who are not org members + // Leave empty to return only teams the user is a member of. + IncludeVisibilities []structs.VisibleType } -func (opts *SearchTeamOptions) toCond() builder.Cond { +func (opts *SearchTeamOptions) applyToSession(sess db.SQLSession) { cond := builder.NewCond() if len(opts.Keyword) > 0 { @@ -68,11 +76,51 @@ func (opts *SearchTeamOptions) toCond() builder.Cond { cond = cond.And(builder.Eq{"`team`.org_id": opts.OrgID}) } - if opts.UserID > 0 { + switch { + case opts.UserID > 0 && len(opts.IncludeVisibilities) > 0: + sess = sess.Join("LEFT", "team_user", "team_user.team_id = team.id AND team_user.uid = ?", opts.UserID) + cond = cond.And(builder.Or( + builder.Eq{"team_user.uid": opts.UserID}, + builder.In("`team`.visibility", opts.IncludeVisibilities), + )) + case opts.UserID > 0: + sess = sess.Join("INNER", "team_user", "team_user.team_id = team.id") cond = cond.And(builder.Eq{"team_user.uid": opts.UserID}) + case len(opts.IncludeVisibilities) > 0: + cond = cond.And(builder.In("`team`.visibility", opts.IncludeVisibilities)) } + sess.Where(cond) +} - return cond +func VisibleTeamVisibilitiesFor(isOrgMember, isSignedIn bool) []structs.VisibleType { + switch { + case isOrgMember: + return []structs.VisibleType{structs.VisibleTypeLimited, structs.VisibleTypePublic} + case isSignedIn: + return []structs.VisibleType{structs.VisibleTypePublic} + default: + return nil + } +} + +func ApplyTeamListFilter(ctx context.Context, orgID int64, viewer *user_model.User, isSignedIn bool, opts *SearchTeamOptions) error { + if viewer.IsAdmin { + return nil + } + isOwner, err := IsOrganizationOwner(ctx, orgID, viewer.ID) + if err != nil { + return err + } + if isOwner { + return nil + } + isOrgMember, err := IsOrganizationMember(ctx, orgID, viewer.ID) + if err != nil { + return err + } + opts.UserID = viewer.ID + opts.IncludeVisibilities = VisibleTeamVisibilitiesFor(isOrgMember, isSignedIn) + return nil } // SearchTeam search for teams. Caller is responsible to check permissions. @@ -80,15 +128,12 @@ func SearchTeam(ctx context.Context, opts *SearchTeamOptions) (TeamList, int64, sess := db.GetEngine(ctx) opts.SetDefaultValues() - cond := opts.toCond() + opts.applyToSession(sess) - if opts.UserID > 0 { - sess = sess.Join("INNER", "team_user", "team_user.team_id = team.id") - } db.SetSessionPagination(sess, opts) teams := make([]*Team, 0, opts.PageSize) - count, err := sess.Where(cond).OrderBy("CASE WHEN name=? THEN '' ELSE lower_name END", OwnerTeamName).FindAndCount(&teams) + count, err := sess.OrderBy("CASE WHEN name=? THEN '' ELSE lower_name END", OwnerTeamName).FindAndCount(&teams) if err != nil { return nil, 0, err } diff --git a/models/organization/team_test.go b/models/organization/team_test.go index aa1cc90caa..30fe6e8229 100644 --- a/models/organization/team_test.go +++ b/models/organization/team_test.go @@ -10,6 +10,8 @@ import ( "gitea.dev/models/organization" repo_model "gitea.dev/models/repo" "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" + "gitea.dev/modules/structs" "github.com/stretchr/testify/assert" ) @@ -38,6 +40,43 @@ func TestTeam_IsMember(t *testing.T) { assert.False(t, team.IsMember(t.Context(), unittest.NonexistentID)) } +func TestTeam_CanNonMemberReadMeta(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // public org + org35 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 35}) // private org + member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // member of org 3 and org 35 + outsider := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) // member of neither org + + test := func(name string, team *organization.Team, org, doer *user_model.User, expected bool) { + t.Run(name, func(t *testing.T) { + ok, err := team.CanNonMemberReadMeta(t.Context(), org, doer) + assert.NoError(t, err) + assert.Equal(t, expected, ok) + }) + } + + // Public team is gated only by the parent org's visibility. + publicTeam := &organization.Team{OrgID: 3, Visibility: structs.VisibleTypePublic} + test("public team, public org, member", publicTeam, org3, member, true) + test("public team, public org, outsider", publicTeam, org3, outsider, true) + + // Public team inside a private org: only org members may see it. + publicTeamPrivOrg := &organization.Team{OrgID: 35, Visibility: structs.VisibleTypePublic} + test("public team, private org, org member", publicTeamPrivOrg, org35, member, true) + test("public team, private org, outsider", publicTeamPrivOrg, org35, outsider, false) + + // Limited team: any org member, but never outsiders. + limitedTeam := &organization.Team{OrgID: 3, Visibility: structs.VisibleTypeLimited} + test("limited team, org member", limitedTeam, org3, member, true) + test("limited team, outsider", limitedTeam, org3, outsider, false) + + // Private team is never visible to non-members; members/owners are admitted by the caller. + privateTeam := &organization.Team{OrgID: 3, Visibility: structs.VisibleTypePrivate} + test("private team, org member", privateTeam, org3, member, false) + test("private team, outsider", privateTeam, org3, outsider, false) +} + func TestTeam_GetRepositories(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) @@ -172,6 +211,52 @@ func TestGetUserOrgTeams(t *testing.T) { test(3, unittest.NonexistentID) } +func TestSearchTeamIncludeVisible(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + const orgID int64 = 3 + // User 5 is an org member but only belongs to team 1 (Owners) — make sure + // they don't see team 2 (default private) but do see a freshly added + // limited team they are not a member of. + visible := &organization.Team{ + OrgID: orgID, + LowerName: "visible-team", + Name: "visible-team", + AccessMode: 1, // read + Visibility: structs.VisibleTypeLimited, + } + assert.NoError(t, db.Insert(t.Context(), visible)) + teams, _, err := organization.SearchTeam(t.Context(), &organization.SearchTeamOptions{ + OrgID: orgID, + UserID: 2, + IncludeVisibilities: organization.VisibleTeamVisibilitiesFor(true, true), + }) + assert.NoError(t, err) + ids := make(map[int64]bool, len(teams)) + for _, team := range teams { + assert.Equal(t, orgID, team.OrgID) + ids[team.ID] = true + } + // user 2 is in team 1 and team 2 in org 3, plus should see the new visible team. + assert.True(t, ids[1], "expected to see team 1 (member)") + assert.True(t, ids[2], "expected to see team 2 (member)") + assert.True(t, ids[visible.ID], "expected to see visible team") + + // user 5 is only an org member in team 1, must not see secret team 2 but must see the visible one. + teams, _, err = organization.SearchTeam(t.Context(), &organization.SearchTeamOptions{ + OrgID: orgID, + UserID: 5, + IncludeVisibilities: organization.VisibleTeamVisibilitiesFor(true, true), + }) + assert.NoError(t, err) + ids = make(map[int64]bool, len(teams)) + for _, team := range teams { + ids[team.ID] = true + } + assert.False(t, ids[2], "user 5 must not see private team 2") + assert.True(t, ids[visible.ID], "user 5 must see the limited team") +} + func TestHasTeamRepo(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) diff --git a/models/repo/repo_list.go b/models/repo/repo_list.go index d0e5dd8f60..57f9e78833 100644 --- a/models/repo/repo_list.go +++ b/models/repo/repo_list.go @@ -783,7 +783,8 @@ func GetUserRepositories(ctx context.Context, opts SearchRepoOptions) (Repositor sess = sess.Where(cond).OrderBy(opts.OrderBy.String()) repos := make(RepositoryList, 0, opts.PageSize) - return repos, count, db.SetSessionPagination(sess, &opts).Find(&repos) + db.SetSessionPagination(sess, &opts) + return repos, count, sess.Find(&repos) } func GetOwnerRepositoriesByIDs(ctx context.Context, ownerID int64, repoIDs []int64) (RepositoryList, error) { diff --git a/modules/htmlutil/html.go b/modules/htmlutil/html.go index 5db1d6404a..a480826bc6 100644 --- a/modules/htmlutil/html.go +++ b/modules/htmlutil/html.go @@ -4,6 +4,7 @@ package htmlutil import ( + "errors" "fmt" "html/template" "io" @@ -88,6 +89,52 @@ func EscapeString(s string) template.HTML { return template.HTML(template.HTMLEscapeString(s)) } +type HTMLWriter interface { + OriginWriter() io.Writer + WriteString(s string) HTMLWriter + WriteHTML(s template.HTML) HTMLWriter + WriteFormat(fmt template.HTML, args ...any) HTMLWriter + Err() error +} + +type htmlWriter struct { + w io.Writer + errs []error +} + +func (h *htmlWriter) OriginWriter() io.Writer { + return h.w +} + +func (h *htmlWriter) WriteString(s string) HTMLWriter { + if _, err := io.WriteString(h.w, template.HTMLEscapeString(s)); err != nil { + h.errs = append(h.errs, err) + } + return h +} + +func (h *htmlWriter) WriteHTML(s template.HTML) HTMLWriter { + if _, err := io.WriteString(h.w, string(s)); err != nil { + h.errs = append(h.errs, err) + } + return h +} + +func (h *htmlWriter) WriteFormat(fmt template.HTML, args ...any) HTMLWriter { + if _, err := HTMLPrintf(h.w, fmt, args...); err != nil { + h.errs = append(h.errs, err) + } + return h +} + +func (h *htmlWriter) Err() error { + return errors.Join(h.errs...) +} + +func NewHTMLWriter(w io.Writer) HTMLWriter { + return &htmlWriter{w: w} +} + type HTMLBuilder struct { sb strings.Builder } diff --git a/modules/htmlutil/html_test.go b/modules/htmlutil/html_test.go index a1ab0a6a49..88e4935a1d 100644 --- a/modules/htmlutil/html_test.go +++ b/modules/htmlutil/html_test.go @@ -5,6 +5,7 @@ package htmlutil import ( "html/template" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -29,3 +30,11 @@ func TestHTMLBuilder(t *testing.T) { assert.Equal(t, "<
>>", b.String()) assert.Equal(t, template.HTML("<
>>"), b.HTMLString()) } + +func TestHTMLWriter(t *testing.T) { + sb := new(strings.Builder) + w := NewHTMLWriter(sb) + w.WriteString("<").WriteHTML("
").WriteFormat("%s%s", ">", EscapeString(">")) + assert.Equal(t, "<
>>", sb.String()) + assert.NoError(t, w.Err()) +} diff --git a/modules/markup/jupyter/jupyter-test.ipynb b/modules/markup/jupyter/jupyter-test.ipynb new file mode 100644 index 0000000000..1bb45f9958 --- /dev/null +++ b/modules/markup/jupyter/jupyter-test.ipynb @@ -0,0 +1,74 @@ +{ + "metadata": {}, + "nbformat": 4, + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "source": ["print('very-looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong')"], + "outputs": [ + { + "output_type": "execute_result", + "text": ["very-looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong ...\n"] + }, + { + "output_type": "stream", + "name": "stdout", + "text": ["stdout 1 ...\n", "stdout 2 ...\n"] + }, + { + "output_type": "stream", + "name": "stderr", + "text": ["stderr ...\n"] + }, + { + "data": { + "text/plain": ["data text 1\n", "data text 2\n"] + } + }, + { + "data": { + "text/plain": true + } + }, + { + "data": { + "image/svg+xml": [""] + } + }, + { + "data": { + "text/html": "HTML Link" + } + }, + { + "data": { + "text/latex": "$$a=1$$" + } + }, + { + "data": { + "text/plain": "plain text" + } + }, + { + "output_type": "error", + "ename": "Error Name", + "traceback": ["stacktrace 1", "stacktrace 2"] + } + ] + }, + { + "cell_type": "unknown-cell" + }, + { + "cell_type": "markdown", + "source": [ + "# h1\n", "## h2\n", "### h3\n", "\n", "paragraph 1\n", "\n", + "very-looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong\n", + "- list item 1\n", "- list item 2\n", "\n", "```python\n", "print('code block')\n", "```\n", + "
th1th2
td1td2
\n" + ] + } + ] +} diff --git a/modules/markup/jupyter/jupyter.go b/modules/markup/jupyter/jupyter.go new file mode 100644 index 0000000000..a02eb4a028 --- /dev/null +++ b/modules/markup/jupyter/jupyter.go @@ -0,0 +1,397 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package jupyter + +import ( + "encoding/base64" + "fmt" + "html/template" + "io" + "strings" + "sync" + + "gitea.dev/modules/highlight" + "gitea.dev/modules/htmlutil" + "gitea.dev/modules/json" + "gitea.dev/modules/log" + "gitea.dev/modules/markup" + "gitea.dev/modules/markup/markdown" + "gitea.dev/modules/setting" + "gitea.dev/modules/util" +) + +func init() { + markup.RegisterRenderer(renderer{}) +} + +// Renderer implements markup.Renderer for Jupyter notebooks +type renderer struct{} + +var ( + _ markup.Renderer = (*renderer)(nil) + _ markup.PostProcessRenderer = (*renderer)(nil) + _ markup.ExternalRenderer = (*renderer)(nil) // FIXME: this is not an external render, need to refactor the framework in the future +) + +type mimeHandler struct { + Mime string + Fn func(w htmlutil.HTMLWriter, data string) error +} + +func renderCellCodeOutputTextPlain(w htmlutil.HTMLWriter, text string) error { + w.WriteFormat(`
%s
`, text) + return w.Err() +} + +func renderCellCodeOutputUnsupported(w htmlutil.HTMLWriter, message string) error { + w.WriteFormat(`
%s
`, message) + return w.Err() +} + +var dataMimeHandlers = sync.OnceValue(func() []mimeHandler { + renderImage := func(w htmlutil.HTMLWriter, subtype, payload string) error { + w.WriteFormat(`
`, subtype, payload) + return w.Err() + } + renderUnsupportedOutput := func(message string) func(htmlutil.HTMLWriter, string) error { + return func(w htmlutil.HTMLWriter, _ string) error { + return renderCellCodeOutputUnsupported(w, message) + } + } + return []mimeHandler{ + // Images (PNG, JPEG, SVG) + {"image/png", func(w htmlutil.HTMLWriter, d string) error { + return renderImage(w, "png", d) + }}, + {"image/jpeg", func(w htmlutil.HTMLWriter, d string) error { + return renderImage(w, "jpeg", d) + }}, + {"image/svg+xml", func(w htmlutil.HTMLWriter, d string) error { + return renderImage(w, "svg+xml", base64.StdEncoding.EncodeToString(util.UnsafeStringToBytes(d))) + }}, + + // Rich & Math Layouts + {"text/html", func(w htmlutil.HTMLWriter, d string) error { + // To future developers: don't allow custom CSS classes or attributes, + // because ".link-action" or "data-fetch-xxx" can send POST requests and lead to XSS. + // If you'd really like to support more, do remember to correctly sanitize the values. + w.WriteFormat(`
%s
`, markup.Sanitize(d)) + return w.Err() + }}, + {"text/latex", func(w htmlutil.HTMLWriter, d string) error { + w.WriteFormat(`
%s
`, trimMathDelimiters(d)) + return w.Err() + }}, + {"text/plain", renderCellCodeOutputTextPlain}, + + // Security Placeholders + {"application/javascript", renderUnsupportedOutput("[JavaScript output - execution disabled for security]")}, + {"application/vnd.plotly.v1+json", renderUnsupportedOutput("[Plotly output - interactive plots not supported]")}, + {"application/vnd.jupyter.widget-view+json", renderUnsupportedOutput("[Jupyter widget - interactive widgets not supported]")}, + } +}) + +func (renderer) Name() string { + return "jupyter-render" +} + +func (renderer) NeedPostProcess() bool { return true } + +func (renderer) GetExternalRendererOptions() markup.ExternalRendererOptions { + return markup.ExternalRendererOptions{ + // HINT: no need to let markup render sanitize the output because there are many special CSS class names, inline attributes. + // This render must guarantee that the output is safe and no XSS + SanitizerDisabled: true, + } +} + +func (renderer) FileNamePatterns() []string { + return []string{"*.ipynb"} +} + +func (renderer) SanitizerRules() []setting.MarkupSanitizerRule { + return nil +} + +// Notebook structures +type Notebook struct { + Cells []Cell `json:"cells"` + Metadata map[string]any `json:"metadata"` + Nbformat int `json:"nbformat"` +} + +type Cell struct { + CellType string `json:"cell_type"` + Source any `json:"source"` // string or []string + Outputs []Output `json:"outputs,omitempty"` + ExecutionCount any `json:"execution_count,omitempty"` // int or null + Metadata map[string]any `json:"metadata,omitempty"` +} + +type Output struct { + OutputType string `json:"output_type"` + Data map[string]any `json:"data,omitempty"` + Text any `json:"text,omitempty"` // string or []string + Name string `json:"name,omitempty"` + Traceback any `json:"traceback,omitempty"` // []string + Ename string `json:"ename,omitempty"` + Evalue string `json:"evalue,omitempty"` +} + +// Render renders Jupyter notebook to HTML +func (renderer) Render(ctx *markup.RenderContext, input io.Reader, outputWriter io.Writer) error { + htmlWriter := htmlutil.NewHTMLWriter(outputWriter) + // the size is (should be) checked and/or limited by the caller to avoid OOM + var notebook Notebook + if err := json.NewDecoder(input).Decode(¬ebook); err != nil { + htmlWriter.WriteFormat(`
Failed to parse notebook JSON: %v
`, err) + return htmlWriter.Err() + } + + // Check nbformat version + if notebook.Nbformat < 4 { + msg := htmlutil.HTMLFormat("This notebook uses an older format (nbformat %d). Only nbformat 4+ is supported for rendering. Please upgrade the notebook in Jupyter or view the raw JSON.", notebook.Nbformat) + htmlWriter.WriteFormat(`
%s
`, msg) + return htmlWriter.Err() + } + + // Detect language + language := "python" // default + if metadata, ok := notebook.Metadata["language_info"].(map[string]any); ok { + if name, ok := metadata["name"].(string); ok { + language = name + } + } else if kernelSpec, ok := notebook.Metadata["kernelspec"].(map[string]any); ok { + if lang, ok := kernelSpec["language"].(string); ok { + language = lang + } + } + + // Start rendering + htmlWriter.WriteHTML(`
`) + + // limiting the cell rendering to 100 cells + cells := notebook.Cells + truncated := false + const maxRenderedCells = 100 + + if len(cells) > maxRenderedCells { + cells = cells[:maxRenderedCells] // Slice down to exactly 100 elements instantly at the pointer layer + truncated = true + } + + for _, cell := range cells { + if err := renderCell(ctx, htmlWriter, cell, language); err != nil { + log.Warn("Failed to render cell: %v", err) // TODO: RENDER-LOG-HANDLING: see other comments + continue + } + } + + if truncated { + renderCellPrompt(htmlWriter, "Warning:", "Output truncated. This notebook contains too many cells to display efficiently.") + } + + htmlWriter.WriteHTML(`
`) + return htmlWriter.Err() +} + +func renderCellCode(output htmlutil.HTMLWriter, cell Cell, language string) error { + source := joinSource(cell.Source) + var executionCount *int64 + if cell.ExecutionCount != nil { + if count, err := util.ToInt64(cell.ExecutionCount); err == nil { + executionCount = &count + } + } + + output.WriteHTML(`
`) + { + if executionCount != nil { + output.WriteFormat(`
In [%d]:
`, *executionCount) + } else { + output.WriteHTML(`
In [ ]:
`) + } + + // Highlight code + lexer := highlight.DetectChromaLexerByFileName("", language) + output.WriteFormat(`
`, strings.ToLower(language))
+		output.WriteHTML(highlight.RenderCodeByLexer(lexer, source))
+		output.WriteHTML("
") + } + output.WriteHTML(`
`) + + // Render outputs + if len(cell.Outputs) > 0 { + hasExecutionResult := false + for _, out := range cell.Outputs { + if out.OutputType == "execute_result" { + hasExecutionResult = true + break + } + } + + output.WriteHTML(`
`) + { + if hasExecutionResult && executionCount != nil { + output.WriteFormat(`
Out [%d]:
`, *executionCount) + } else { + output.WriteHTML(`
`) + } + + output.WriteHTML(`
`) + for _, out := range cell.Outputs { + renderCellCodeOutput(output, out) + } + output.WriteHTML(`
`) + } + output.WriteHTML(`
`) + } + + return output.Err() +} + +func renderCellPrompt(output htmlutil.HTMLWriter, left, right template.HTML) { + output.WriteFormat(` +
+
+
%s
+
%s
+
+
`, left, right) +} + +func renderCell(ctx *markup.RenderContext, output htmlutil.HTMLWriter, cell Cell, language string) error { + switch cell.CellType { + case "markdown": + output.WriteHTML(` +
+
+
+
`) + if err := renderCellMarkdown(ctx, output, joinSource(cell.Source)); err != nil { + return err + } + output.WriteHTML(` +
+
+
`) + case "code": + output.WriteHTML(`
`) + if err := renderCellCode(output, cell, language); err != nil { + return err + } + output.WriteHTML(`
`) + default: + renderCellPrompt(output, "Cell:", htmlutil.HTMLFormat("[Cell type %s - unsupported, skipped]", cell.CellType)) + } + return output.Err() +} + +func renderCellMarkdown(rctx *markup.RenderContext, output htmlutil.HTMLWriter, source string) error { + markdownCtx := markup.NewRenderContext(rctx) + // make sure the markdown render use the same options and helper to generate correct contents (e.g.: links) + markdownCtx.RenderOptions = rctx.RenderOptions + markdownCtx.RenderHelper = rctx.RenderHelper + output.WriteHTML(`
`) + if err := markdown.Render(markdownCtx, strings.NewReader(source), output.OriginWriter()); err != nil { + return err + } + output.WriteHTML(`
`) + return output.Err() +} + +func renderCellCodeOutput(output htmlutil.HTMLWriter, out Output) { + if out.Data != nil { + // Iterate through our priority list to find the best matching MIME handler available + for _, h := range dataMimeHandlers() { + if rawPayload, exists := out.Data[h.Mime]; exists { + var stringPayload string + + // Flatten the polymorphic JSON input (string or []any) into a single clean string + switch v := rawPayload.(type) { + case string: + stringPayload = v + case []any: + stringPayload = joinSource(v) + default: + _ = renderCellCodeOutputUnsupported(output, fmt.Sprintf("[Data output - unsupported data type %T for mime type %s]", rawPayload, h.Mime)) + continue + } + + if err := h.Fn(output, stringPayload); err != nil { + // TODO: RENDER-LOG-HANDLING: outputting render's error to sever's log is not a proper approach + // The errors can be: + // * unsupported element (cell, data, etc): it should render the message on the UI to tell users that the content is not supported, or ignore them if they are ignore-able + // * logic error: it should report to server logs + // * network error: io.Writer tries to write to the HTTP connection, so the error can also be a network error, such error should be ignored + log.Error("Jupyter rendering engine failed for MIME type %s: %v", h.Mime, err) + } + + // Return immediately after rendering the top matching priority format + return + } + } + } + + // Stream output + if out.OutputType == "stream" && out.Text != nil { + streamName := util.Iif(out.Name == "stderr", "stderr", "stdout") + output.WriteFormat(`
%s
`, streamName, joinSource(out.Text)) + return + } + + // Error output + if out.OutputType == "error" { + traceback := "" + if tb, ok := out.Traceback.([]any); ok { + lines := make([]string, len(tb)) + for i, line := range tb { + lines[i] = fmt.Sprint(line) + } + traceback = strings.Join(lines, "\n") + } + if traceback == "" && out.Ename != "" { + traceback = fmt.Sprintf("%s: %s", out.Ename, out.Evalue) + } + output.WriteFormat(`
%s
`, traceback) + return + } + + // Generic text output + if out.Text != nil { + _ = renderCellCodeOutputTextPlain(output, joinSource(out.Text)) + } +} + +func joinSource(source any) string { + switch v := source.(type) { + case nil: + return "" + case string: + return v + case []any: + // the "source slice item" has EOL ("\n"), so just join them together + parts := make([]string, len(v)) + for i, part := range v { + parts[i] = fmt.Sprint(part) + } + return strings.Join(parts, "") + default: + return fmt.Sprint(v) + } +} + +// trimMathDelimiters strips a single pair of surrounding math delimiters ("$$...$$" or "$...$"), +// so the inner expression is handled by the math post-processor. Unlike strings.Trim, it does not +// eat unrelated "$" characters elsewhere in multi-expression content. +func trimMathDelimiters(s string) string { + s = strings.TrimSpace(s) + if t, ok := strings.CutPrefix(s, "$$"); ok { + return strings.TrimSuffix(t, "$$") + } + if t, ok := strings.CutPrefix(s, "$"); ok { + return strings.TrimSuffix(t, "$") + } + return s +} diff --git a/modules/markup/jupyter/jupyter_test.go b/modules/markup/jupyter/jupyter_test.go new file mode 100644 index 0000000000..61d362da98 --- /dev/null +++ b/modules/markup/jupyter/jupyter_test.go @@ -0,0 +1,314 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package jupyter + +import ( + "fmt" + "strings" + "testing" + + "gitea.dev/modules/markup" + "gitea.dev/modules/test" + + "github.com/stretchr/testify/assert" +) + +func TestRender(t *testing.T) { + r := renderer{} + + t.Run("Basic notebook", func(t *testing.T) { + input := `{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "source": ["print('hello')"], + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": ["hello\n"] + } + ] + } + ], + "metadata": {}, + "nbformat": 4 + }` + + var output strings.Builder + ctx := &markup.RenderContext{} + err := r.Render(ctx, strings.NewReader(input), &output) + + assert.NoError(t, err) + result := output.String() + assert.Contains(t, result, `
`) + assert.Contains(t, result, `
`) + assert.Contains(t, result, `In [1]:`) + assert.Contains(t, result, `print`) + assert.Contains(t, result, `hello`) + assert.Contains(t, result, `stream-stdout`) + }) + + t.Run("Markdown cell with XSS Protection", func(t *testing.T) { + input := `{ + "cells": [ + { + "cell_type": "markdown", + "source": [ + "# Title\n", + "Some text\n", + "[click me](javascript:alert(1))\n", + "" + ] + } + ], + "metadata": {}, + "nbformat": 4 + }` + + var output strings.Builder + ctx := markup.NewRenderContext(t.Context()) + err := r.Render(ctx, strings.NewReader(input), &output) + + assert.NoError(t, err) + result := output.String() + + // Assert normal markup still renders correctly + assert.Contains(t, result, `
`) + assert.Contains(t, result, `Title`) + assert.Contains(t, result, `Some text`) + assert.Contains(t, result, `click me`) + + // CRITICAL SECURITY ASSERTIONS: Ensure XSS vectors are completely stripped + assert.NotContains(t, result, `javascript:alert`) + assert.NotContains(t, result, `
Safe Content
" + ] + }, + "metadata": {} + } + ] + } + ] + }` + + var output strings.Builder + ctx := markup.NewRenderContext(t.Context()) + ctx.RenderOptions.MarkupType = "jupyter-render" + err := markup.Render(ctx, strings.NewReader(maliciousNotebook), &output) + assert.NoError(t, err) + const expected = ` +
+
+
+
In [1]:
+
+

+					a=1
+				
+
+
+
+
Out [1]:
+
+
+
Safe Content
+
+
+
+
+
` + assert.Equal(t, test.NormalizeHTMLSpaces(expected), test.NormalizeHTMLSpaces(output.String())) +} diff --git a/modules/sitemap/sitemap.go b/modules/sitemap/sitemap.go index 280ca1d710..5ac37033d9 100644 --- a/modules/sitemap/sitemap.go +++ b/modules/sitemap/sitemap.go @@ -63,20 +63,24 @@ func (s *Sitemap) Add(u URL) { // WriteTo writes the sitemap to a response func (s *Sitemap) WriteTo(w io.Writer) (int64, error) { if l := len(s.URLs); l > urlsLimit { - return 0, fmt.Errorf("The sitemap contains %d URLs, but only %d are allowed", l, urlsLimit) + return 0, fmt.Errorf("sitemap contains %d URLs, but only %d are allowed", l, urlsLimit) } if l := len(s.Sitemaps); l > urlsLimit { - return 0, fmt.Errorf("The sitemap contains %d sub-sitemaps, but only %d are allowed", l, urlsLimit) + return 0, fmt.Errorf("sitemap contains %d sub-sitemaps, but only %d are allowed", l, urlsLimit) } buf := bytes.NewBufferString(xml.Header) - if err := xml.NewEncoder(buf).Encode(s); err != nil { + encoder := xml.NewEncoder(buf) + defer encoder.Close() + if err := encoder.Encode(s); err != nil { return 0, err } + _ = encoder.Flush() if err := buf.WriteByte('\n'); err != nil { return 0, err } + // FIXME: such limit is not right, the content has been written, it would have already caused OOM if buf.Len() > sitemapFileLimit { - return 0, fmt.Errorf("The sitemap has %d bytes, but only %d are allowed", buf.Len(), sitemapFileLimit) + return 0, fmt.Errorf("sitemap has %d bytes, but only %d are allowed", buf.Len(), sitemapFileLimit) } return buf.WriteTo(w) } diff --git a/modules/sitemap/sitemap_test.go b/modules/sitemap/sitemap_test.go index 1180463cd7..9ff9793901 100644 --- a/modules/sitemap/sitemap_test.go +++ b/modules/sitemap/sitemap_test.go @@ -61,14 +61,14 @@ func TestNewSitemap(t *testing.T) { { name: "too many urls", urls: make([]URL, 50001), - wantErr: "The sitemap contains 50001 URLs, but only 50000 are allowed", + wantErr: "sitemap contains 50001 URLs, but only 50000 are allowed", }, { name: "too big file", urls: []URL{ {URL: strings.Repeat("b", 50*1024*1024+1)}, }, - wantErr: "The sitemap has 52428932 bytes, but only 52428800 are allowed", + wantErr: "sitemap has 52428932 bytes, but only 52428800 are allowed", }, } for _, tt := range tests { @@ -137,14 +137,14 @@ func TestNewSitemapIndex(t *testing.T) { { name: "too many sitemaps", urls: make([]URL, 50001), - wantErr: "The sitemap contains 50001 sub-sitemaps, but only 50000 are allowed", + wantErr: "sitemap contains 50001 sub-sitemaps, but only 50000 are allowed", }, { name: "too big file", urls: []URL{ {URL: strings.Repeat("b", 50*1024*1024+1)}, }, - wantErr: "The sitemap has 52428952 bytes, but only 52428800 are allowed", + wantErr: "sitemap has 52428952 bytes, but only 52428800 are allowed", }, } for _, tt := range tests { diff --git a/modules/structs/org_team.go b/modules/structs/org_team.go index 20959931d3..c9542951a0 100644 --- a/modules/structs/org_team.go +++ b/modules/structs/org_team.go @@ -4,6 +4,20 @@ package structs +// TeamVisibility controls who can list a team within its organization. +// - "public": visible to any signed-in user (still bounded by org visibility) +// - "limited": visible to any member of the parent organization +// - "private": visible only to team members and org owners +// +// swagger:enum TeamVisibility +type TeamVisibility string + +const ( + TeamVisibilityPublic TeamVisibility = "public" + TeamVisibilityLimited TeamVisibility = "limited" + TeamVisibilityPrivate TeamVisibility = "private" +) + // Team represents a team in an organization type Team struct { // The unique identifier of the team @@ -24,6 +38,11 @@ type Team struct { UnitsMap map[string]string `json:"units_map"` // Whether the team can create repositories in the organization CanCreateOrgRepo bool `json:"can_create_org_repo"` + // Team visibility within the organization. "private" teams are only + // listable by members and org owners; "limited" teams are listable by + // any organization member; "public" teams are listable by any signed-in + // user. + Visibility TeamVisibility `json:"visibility"` } // CreateTeamOption options for creating a team @@ -42,6 +61,8 @@ type CreateTeamOption struct { UnitsMap map[string]string `json:"units_map"` // Whether the team can create repositories in the organization CanCreateOrgRepo bool `json:"can_create_org_repo"` + // Team visibility within the organization. Defaults to "private". + Visibility TeamVisibility `json:"visibility" binding:"OmitEmpty;In(public,limited,private)"` } // EditTeamOption options for editing a team @@ -60,4 +81,7 @@ type EditTeamOption struct { UnitsMap map[string]string `json:"units_map"` // Whether the team can create repositories in the organization CanCreateOrgRepo *bool `json:"can_create_org_repo"` + // Team visibility within the organization. When omitted, visibility is + // left unchanged. + Visibility *TeamVisibility `json:"visibility" binding:"OmitEmpty;In(public,limited,private)"` } diff --git a/modules/structs/token.go b/modules/structs/token.go new file mode 100644 index 0000000000..af72aca487 --- /dev/null +++ b/modules/structs/token.go @@ -0,0 +1,31 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +import "time" + +// CurrentAccessToken represents the metadata of the currently authenticated token. +// swagger:model CurrentAccessToken +type CurrentAccessToken struct { + // The unique identifier of the access token + ID int64 `json:"id"` + // The name of the access token + Name string `json:"name"` + // The scopes granted to this access token + Scopes []string `json:"scopes"` + // The timestamp when the token was created + CreatedAt time.Time `json:"created_at"` + // The timestamp when the token was last used + LastUsedAt time.Time `json:"last_used_at"` + // The owner of the access token + User *UserMeta `json:"user"` +} + +// UserMeta represents minimal user information for the token owner. +type UserMeta struct { + // The unique identifier of the user + ID int64 `json:"id"` + // The username of the user + Login string `json:"login"` +} diff --git a/modules/test/utils.go b/modules/test/utils.go index 5a4e13b423..12b35f42f7 100644 --- a/modules/test/utils.go +++ b/modules/test/utils.go @@ -12,12 +12,16 @@ import ( "net/http" "net/http/httptest" "os" + "regexp" + "slices" "strconv" "strings" "sync" "gitea.dev/modules/json" "gitea.dev/modules/util" + + "golang.org/x/net/html" ) // RedirectURL returns the redirect URL of a http response. @@ -182,3 +186,48 @@ func ExternalServiceHTTP(t TestingT, envVarName, def string) string { } return val } + +var normalizeHTMLSpacesRegexp = sync.OnceValue(func() (ret struct { + afterRt, beforeLt *regexp.Regexp +}, +) { + ret.afterRt = regexp.MustCompile(`>\s*`) + ret.beforeLt = regexp.MustCompile(`\s*<`) + return ret +}) + +func NormalizeHTMLSpaces(s string) string { + vars := normalizeHTMLSpacesRegexp() + s = vars.afterRt.ReplaceAllString(s, ">\n") + s = vars.beforeLt.ReplaceAllString(s, "\n<") + return strings.TrimSpace(s) +} + +func NormalizeHTMLAttributes(t TestingT, s string) string { + nodes, err := html.Parse(strings.NewReader(s)) + if err != nil { + t.Errorf("failed to parse expected HTML: %v", err) + return "" + } + + var normalize func(n *html.Node) + normalize = func(n *html.Node) { + slices.SortFunc(n.Attr, func(a, b html.Attribute) int { + if cmp := strings.Compare(a.Namespace, b.Namespace); cmp != 0 { + return cmp + } + if cmp := strings.Compare(a.Key, b.Key); cmp != 0 { + return cmp + } + return strings.Compare(a.Val, b.Val) + }) + for c := n.FirstChild; c != nil; c = c.NextSibling { + normalize(c) + } + } + var sb strings.Builder + if err = html.Render(&sb, nodes); err != nil { + t.Errorf("failed to render HTML: %v", err) + } + return sb.String() +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 4498502703..f2819d55ed 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2882,6 +2882,14 @@ "org.teams.all_repositories_read_permission_desc": "This team grants Read access to all repositories: members can view and clone repositories.", "org.teams.all_repositories_write_permission_desc": "This team grants Write access to all repositories: members can read from and push to repositories.", "org.teams.all_repositories_admin_permission_desc": "This team grants Admin access to all repositories: members can read from, push to and add collaborators to repositories.", + "org.teams.visibility": "Visibility", + "org.teams.visibility_private": "Private", + "org.teams.visibility_private_helper": "Visible only to team members and organization owners.", + "org.teams.visibility_limited": "Limited", + "org.teams.visibility_limited_helper": "Visible to all members of this organization.", + "org.teams.visibility_public": "Public", + "org.teams.visibility_public_helper": "Visible to any signed-in user.", + "org.teams.owners_visibility_fixed": "The Owners team visibility cannot be changed.", "org.teams.invite.title": "You have been invited to join team %s in organization %s.", "org.teams.invite.by": "Invited by %s", "org.teams.invite.description": "Please click the button below to join the team.", diff --git a/package.json b/package.json index 8b313bdfed..ff7f8f1be0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "type": "module", - "packageManager": "pnpm@11.5.1", + "packageManager": "pnpm@11.5.3", "engines": { "node": ">= 22.18.0", "pnpm": ">= 11.0.0" @@ -17,10 +17,10 @@ "@codemirror/language": "6.12.3", "@codemirror/language-data": "6.5.2", "@codemirror/legacy-modes": "6.5.3", - "@codemirror/lint": "6.9.6", + "@codemirror/lint": "6.9.7", "@codemirror/search": "6.7.0", "@codemirror/state": "6.6.0", - "@codemirror/view": "6.43.0", + "@codemirror/view": "6.43.1", "@deltablot/dropzone": "7.4.3", "@github/markdown-toolbar-element": "2.2.3", "@github/paste-markdown": "1.5.3", @@ -28,7 +28,7 @@ "@lezer/highlight": "1.2.3", "@mcaptcha/vanilla-glue": "0.1.0-rc2", "@mermaid-js/layout-elk": "0.2.1", - "@primer/octicons": "19.28.0", + "@primer/octicons": "19.28.1", "@replit/codemirror-indentation-markers": "6.5.3", "@replit/codemirror-lang-nix": "6.0.1", "@replit/codemirror-lang-svelte": "6.0.0", @@ -80,18 +80,18 @@ "@stylistic/eslint-plugin": "5.10.0", "@stylistic/stylelint-plugin": "5.2.0", "@types/codemirror": "5.60.17", - "@types/jquery": "4.0.0", + "@types/jquery": "4.0.1", "@types/js-yaml": "4.0.9", "@types/katex": "0.16.8", - "@types/node": "25.9.1", + "@types/node": "25.9.2", "@types/pdfobject": "2.2.5", "@types/sortablejs": "1.15.9", "@types/swagger-ui-dist": "3.30.6", "@types/throttle-debounce": "5.0.2", "@types/toastify-js": "1.12.4", - "@typescript-eslint/parser": "8.60.1", + "@typescript-eslint/parser": "8.61.0", "@vitejs/plugin-vue": "6.0.7", - "@vitest/eslint-plugin": "1.6.19", + "@vitest/eslint-plugin": "1.6.20", "eslint": "10.4.1", "eslint-import-resolver-typescript": "4.4.5", "eslint-plugin-array-func": "5.1.1", @@ -106,22 +106,22 @@ "eslint-plugin-vue-scoped-css": "3.1.1", "eslint-plugin-wc": "3.1.0", "globals": "17.6.0", - "happy-dom": "20.10.1", + "happy-dom": "20.10.2", "jiti": "2.7.0", "markdownlint-cli": "0.48.0", "material-icon-theme": "5.35.0", "postcss-html": "1.8.1", "spectral-cli-bundle": "1.0.8", - "stylelint": "17.12.0", + "stylelint": "17.13.0", "stylelint-config-recommended": "18.0.0", "stylelint-declaration-block-no-ignored-properties": "3.0.0", "stylelint-declaration-strict-value": "1.11.1", "stylelint-value-no-unknown-custom-properties": "6.1.1", "svgo": "4.0.1", "typescript": "6.0.3", - "typescript-eslint": "8.60.1", - "updates": "17.17.3", + "typescript-eslint": "8.61.0", + "updates": "17.18.0", "vitest": "4.1.8", - "vue-tsc": "3.3.3" + "vue-tsc": "3.3.4" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c30124ac03..61615ef6f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,8 +42,8 @@ importers: specifier: 6.5.3 version: 6.5.3 '@codemirror/lint': - specifier: 6.9.6 - version: 6.9.6 + specifier: 6.9.7 + version: 6.9.7 '@codemirror/search': specifier: 6.7.0 version: 6.7.0 @@ -51,8 +51,8 @@ importers: specifier: 6.6.0 version: 6.6.0 '@codemirror/view': - specifier: 6.43.0 - version: 6.43.0 + specifier: 6.43.1 + version: 6.43.1 '@deltablot/dropzone': specifier: 7.4.3 version: 7.4.3 @@ -75,26 +75,26 @@ importers: specifier: 0.2.1 version: 0.2.1(mermaid@11.15.0) '@primer/octicons': - specifier: 19.28.0 - version: 19.28.0 + specifier: 19.28.1 + version: 19.28.1 '@replit/codemirror-indentation-markers': specifier: 6.5.3 - version: 6.5.3(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0) + version: 6.5.3(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.43.1) '@replit/codemirror-lang-nix': specifier: 6.0.1 - version: 6.0.1(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@lezer/lr@1.4.10) + version: 6.0.1(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.43.1)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@lezer/lr@1.4.10) '@replit/codemirror-lang-svelte': specifier: 6.0.0 - version: 6.0.0(@codemirror/autocomplete@6.20.3)(@codemirror/lang-css@6.3.1)(@codemirror/lang-html@6.4.11)(@codemirror/lang-javascript@6.2.5)(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@lezer/javascript@1.5.4)(@lezer/lr@1.4.10) + version: 6.0.0(@codemirror/autocomplete@6.20.3)(@codemirror/lang-css@6.3.1)(@codemirror/lang-html@6.4.11)(@codemirror/lang-javascript@6.2.5)(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.43.1)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@lezer/javascript@1.5.4)(@lezer/lr@1.4.10) '@replit/codemirror-vscode-keymap': specifier: 6.0.2 - version: 6.0.2(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.6)(@codemirror/search@6.7.0)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0) + version: 6.0.2(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.0)(@codemirror/state@6.6.0)(@codemirror/view@6.43.1) '@resvg/resvg-wasm': specifier: 2.6.2 version: 2.6.2 '@vitejs/plugin-vue': specifier: 6.0.7 - version: 6.0.7(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0))(vue@3.5.35(typescript@6.0.3)) + version: 6.0.7(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0))(vue@3.5.35(typescript@6.0.3)) ansi_up: specifier: 6.0.6 version: 6.0.6 @@ -193,10 +193,10 @@ importers: version: 0.7.2 vite: specifier: 8.0.16 - version: 8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0) + version: 8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0) vite-string-plugin: specifier: 2.0.4 - version: 2.0.4(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)) + version: 2.0.4(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)) vue: specifier: 3.5.35 version: 3.5.35(typescript@6.0.3) @@ -221,13 +221,13 @@ importers: version: 5.10.0(eslint@10.4.1(jiti@2.7.0)) '@stylistic/stylelint-plugin': specifier: 5.2.0 - version: 5.2.0(stylelint@17.12.0(typescript@6.0.3)) + version: 5.2.0(stylelint@17.13.0(typescript@6.0.3)) '@types/codemirror': specifier: 5.60.17 version: 5.60.17 '@types/jquery': - specifier: 4.0.0 - version: 4.0.0 + specifier: 4.0.1 + version: 4.0.1 '@types/js-yaml': specifier: 4.0.9 version: 4.0.9 @@ -235,8 +235,8 @@ importers: specifier: 0.16.8 version: 0.16.8 '@types/node': - specifier: 25.9.1 - version: 25.9.1 + specifier: 25.9.2 + version: 25.9.2 '@types/pdfobject': specifier: 2.2.5 version: 2.2.5 @@ -253,17 +253,17 @@ importers: specifier: 1.12.4 version: 1.12.4 '@typescript-eslint/parser': - specifier: 8.60.1 - version: 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) + specifier: 8.61.0 + version: 8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) '@vitest/eslint-plugin': - specifier: 1.6.19 - version: 1.6.19(@typescript-eslint/eslint-plugin@8.60.1(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.8(@types/node@25.9.1)(happy-dom@20.10.1)(jsdom@20.0.3)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0))) + specifier: 1.6.20 + version: 1.6.20(@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.8(@types/node@25.9.2)(happy-dom@20.10.2)(jsdom@20.0.3)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0))) eslint: specifier: 10.4.1 version: 10.4.1(jiti@2.7.0) eslint-import-resolver-typescript: specifier: 4.4.5 - version: 4.4.5(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.4.1(jiti@2.7.0)))(eslint-plugin-import@2.32.0)(eslint@10.4.1(jiti@2.7.0)) + version: 4.4.5(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.4.1(jiti@2.7.0)))(eslint-plugin-import@2.32.0)(eslint@10.4.1(jiti@2.7.0)) eslint-plugin-array-func: specifier: 5.1.1 version: 5.1.1(eslint@10.4.1(jiti@2.7.0)) @@ -275,7 +275,7 @@ importers: version: 6.0.0(eslint-import-resolver-typescript@4.4.5)(eslint@10.4.1(jiti@2.7.0)) eslint-plugin-import-x: specifier: 4.16.2 - version: 4.16.2(@typescript-eslint/utils@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.4.1(jiti@2.7.0)) + version: 4.16.2(@typescript-eslint/utils@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.4.1(jiti@2.7.0)) eslint-plugin-playwright: specifier: 2.10.4 version: 2.10.4(eslint@10.4.1(jiti@2.7.0)) @@ -290,7 +290,7 @@ importers: version: 64.0.0(eslint@10.4.1(jiti@2.7.0)) eslint-plugin-vue: specifier: 10.9.2 - version: 10.9.2(@stylistic/eslint-plugin@5.10.0(eslint@10.4.1(jiti@2.7.0)))(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(vue-eslint-parser@10.4.0(eslint@10.4.1(jiti@2.7.0))) + version: 10.9.2(@stylistic/eslint-plugin@5.10.0(eslint@10.4.1(jiti@2.7.0)))(@typescript-eslint/parser@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(vue-eslint-parser@10.4.0(eslint@10.4.1(jiti@2.7.0))) eslint-plugin-vue-scoped-css: specifier: 3.1.1 version: 3.1.1(eslint@10.4.1(jiti@2.7.0))(vue-eslint-parser@10.4.0(eslint@10.4.1(jiti@2.7.0))) @@ -301,8 +301,8 @@ importers: specifier: 17.6.0 version: 17.6.0 happy-dom: - specifier: 20.10.1 - version: 20.10.1 + specifier: 20.10.2 + version: 20.10.2 jiti: specifier: 2.7.0 version: 2.7.0 @@ -319,20 +319,20 @@ importers: specifier: 1.0.8 version: 1.0.8 stylelint: - specifier: 17.12.0 - version: 17.12.0(typescript@6.0.3) + specifier: 17.13.0 + version: 17.13.0(typescript@6.0.3) stylelint-config-recommended: specifier: 18.0.0 - version: 18.0.0(stylelint@17.12.0(typescript@6.0.3)) + version: 18.0.0(stylelint@17.13.0(typescript@6.0.3)) stylelint-declaration-block-no-ignored-properties: specifier: 3.0.0 - version: 3.0.0(stylelint@17.12.0(typescript@6.0.3)) + version: 3.0.0(stylelint@17.13.0(typescript@6.0.3)) stylelint-declaration-strict-value: specifier: 1.11.1 - version: 1.11.1(stylelint@17.12.0(typescript@6.0.3)) + version: 1.11.1(stylelint@17.13.0(typescript@6.0.3)) stylelint-value-no-unknown-custom-properties: specifier: 6.1.1 - version: 6.1.1(stylelint@17.12.0(typescript@6.0.3)) + version: 6.1.1(stylelint@17.13.0(typescript@6.0.3)) svgo: specifier: 4.0.1 version: 4.0.1 @@ -340,17 +340,17 @@ importers: specifier: 6.0.3 version: 6.0.3 typescript-eslint: - specifier: 8.60.1 - version: 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) + specifier: 8.61.0 + version: 8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) updates: - specifier: 17.17.3 - version: 17.17.3 + specifier: 17.18.0 + version: 17.18.0 vitest: specifier: 4.1.8 - version: 4.1.8(@types/node@25.9.1)(happy-dom@20.10.1)(jsdom@20.0.3)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.8(@types/node@25.9.2)(happy-dom@20.10.2)(jsdom@20.0.3)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)) vue-tsc: - specifier: 3.3.3 - version: 3.3.3(typescript@6.0.3) + specifier: 3.3.4 + version: 3.3.4(typescript@6.0.3) packages: @@ -524,8 +524,8 @@ packages: '@codemirror/legacy-modes@6.5.3': resolution: {integrity: sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==} - '@codemirror/lint@6.9.6': - resolution: {integrity: sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==} + '@codemirror/lint@6.9.7': + resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==} '@codemirror/search@6.7.0': resolution: {integrity: sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==} @@ -533,8 +533,8 @@ packages: '@codemirror/state@6.6.0': resolution: {integrity: sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==} - '@codemirror/view@6.43.0': - resolution: {integrity: sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==} + '@codemirror/view@6.43.1': + resolution: {integrity: sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==} '@csstools/css-calc@3.2.1': resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} @@ -999,8 +999,8 @@ packages: '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} - '@primer/octicons@19.28.0': - resolution: {integrity: sha512-FCpW9ZXI9U9h7wjYSXFQK4Zyp1Roc/kF8nymak4bYccWaWoUixbnIr4u8UYiRoPRSglm+23TZEyUZHrgNql9Jw==} + '@primer/octicons@19.28.1': + resolution: {integrity: sha512-pwSilXmgNrbVF2bChkh4zZtUyb4Vr4niYhA9PhUdtjVz86A2iwA/YjjopHS0suT+I7niUZJEepEpmSC7kARKNQ==} '@replit/codemirror-indentation-markers@6.5.3': resolution: {integrity: sha512-hL5Sfvw3C1vgg7GolLe/uxX5T3tmgOA3ZzqlMv47zjU1ON51pzNWiVbS22oh6crYhtVhv8b3gdXwoYp++2ilHw==} @@ -1335,8 +1335,8 @@ packages: '@types/istanbul-reports@3.0.4': resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} - '@types/jquery@4.0.0': - resolution: {integrity: sha512-Z+to+A2VkaHq1DfI2oSwsoCdhCHMpTSgjWzNcbNlRGYzksDBpPUgEcAL+RQjOBJRaLoEAOHXxqDGBVP+BblBwg==} + '@types/jquery@4.0.1': + resolution: {integrity: sha512-9a59A/tycXgYuPABcp6/3spSShn0NT2UOM4EfHvMumjYi4lJWTsK5SZWjhx3yRm9IHGCeWXdV2YfNsrWrft/CA==} '@types/js-yaml@4.0.9': resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} @@ -1359,8 +1359,8 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@25.9.1': - resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} + '@types/node@25.9.2': + resolution: {integrity: sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==} '@types/pdfobject@2.2.5': resolution: {integrity: sha512-7gD5tqc/RUDq0PyoLemL0vEHxBYi+zY0WVaFAx/Y0jBsXFgot1vB9No1GhDZGwRGJMCIZbgAb74QG9MTyTNU/g==} @@ -1404,63 +1404,63 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.60.1': - resolution: {integrity: sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==} + '@typescript-eslint/eslint-plugin@8.61.0': + resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.60.1 + '@typescript-eslint/parser': ^8.61.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.60.1': - resolution: {integrity: sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==} + '@typescript-eslint/parser@8.61.0': + resolution: {integrity: sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.60.1': - resolution: {integrity: sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==} + '@typescript-eslint/project-service@8.61.0': + resolution: {integrity: sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.60.1': - resolution: {integrity: sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==} + '@typescript-eslint/scope-manager@8.61.0': + resolution: {integrity: sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.60.1': - resolution: {integrity: sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==} + '@typescript-eslint/tsconfig-utils@8.61.0': + resolution: {integrity: sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.60.1': - resolution: {integrity: sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==} + '@typescript-eslint/type-utils@8.61.0': + resolution: {integrity: sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.60.1': - resolution: {integrity: sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==} + '@typescript-eslint/types@8.61.0': + resolution: {integrity: sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.60.1': - resolution: {integrity: sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==} + '@typescript-eslint/typescript-estree@8.61.0': + resolution: {integrity: sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.60.1': - resolution: {integrity: sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==} + '@typescript-eslint/utils@8.61.0': + resolution: {integrity: sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.60.1': - resolution: {integrity: sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==} + '@typescript-eslint/visitor-keys@8.61.0': + resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@unrs/resolver-binding-android-arm-eabi@1.12.2': @@ -1593,8 +1593,8 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 vue: ^3.2.25 - '@vitest/eslint-plugin@1.6.19': - resolution: {integrity: sha512-zodmXRsVKFsuHxHJILuTFaaKsrsxm0YsiOX65clk+LpCW9JrVXaf6ERXr0caDs+NEk0S62Jyk0K7XYQ7gWXheA==} + '@vitest/eslint-plugin@1.6.20': + resolution: {integrity: sha512-xRwWHFG0Utp6hXtbGiWk4VdKXCGdExD8kbWrrmFEiG5dk8anOJ+vbWbeOa8EbkocKQRTsx7JAWETccZiBgFp/Q==} engines: {node: '>=18'} peerDependencies: '@typescript-eslint/eslint-plugin': '*' @@ -1659,8 +1659,8 @@ packages: '@vue/compiler-ssr@3.5.35': resolution: {integrity: sha512-rGhAeXgdM7/ffTJGXT69rCCdTmjDewnFuUZfBQQHTdcEBeWdT5HCGY60y2ytLJr9/Dsu7IntUi5z/w0h6Rjnzw==} - '@vue/language-core@3.3.3': - resolution: {integrity: sha512-X6p+7nfY7vVT6dQwUJ+v0Jfq/lwIfhL2jMi91dQ3ln4hnlGXlxsDu/FNkeyHYgvYtyQy18ZX76IZy7X4diDbiQ==} + '@vue/language-core@3.3.4': + resolution: {integrity: sha512-IuHqQ5zGGOE7CXP72VX6A42IVeIzYv4WAhO6arej11TRNqtdZfGyH8Yr2FOCaDX0dSQG+JwULLoFHGY1igYVjQ==} '@vue/reactivity@3.5.35': resolution: {integrity: sha512-tVc+SsHConvh/Lz64qq1pP3rYArBmK42xonovEcxY74SQtvctZodG/zhq54P5dr38cVuw25d27cPNRdlMidpGQ==} @@ -3010,8 +3010,8 @@ packages: resolution: {integrity: sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==} engines: {node: '>=0.8.0'} - happy-dom@20.10.1: - resolution: {integrity: sha512-awPoqPjx8CgjapJllyDlgzgVHjBExcitKK5ZJkxwhQJyQpHFkyS2bEcqCm7IeW20cQvuCI0cz2Ifq79CJKqtiw==} + happy-dom@20.10.2: + resolution: {integrity: sha512-5p9Sxis3eowDJKqx90QCsgbNA02XXqJ59NOHvD4V6cxp+rP4d/xOyVx7uY3hS8hiUbY1VeiFH8lbJ81AyuDVLQ==} engines: {node: '>=20.0.0'} har-schema@2.0.0: @@ -4461,8 +4461,8 @@ packages: peerDependencies: stylelint: '>=16' - stylelint@17.12.0: - resolution: {integrity: sha512-KIlzWXMHUvgfPUR0R7TK3H80yCIi0uoivUwf+6Az4yrHJD1Q3c1qIkh/H5Z0i/K3QXgtq/UMEkWyBUSUwnpnOg==} + stylelint@17.13.0: + resolution: {integrity: sha512-G1WYzMerp7ihOaIe9VJCHLt12MoAD2QLf1AFerYP37+BCRBUK5UCpq8e/mN+zCIaJPKQcaxhE4WlPmqdiOx/gw==} engines: {node: '>=20.19.0'} hasBin: true @@ -4640,8 +4640,8 @@ packages: resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} engines: {node: '>= 0.4'} - typescript-eslint@8.60.1: - resolution: {integrity: sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==} + typescript-eslint@8.61.0: + resolution: {integrity: sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -4694,8 +4694,8 @@ packages: peerDependencies: browserslist: '>= 4.21.0' - updates@17.17.3: - resolution: {integrity: sha512-ZIhWarBUBmKG65d0AeOOMlZFonGWn6Ntol4/epga/xbQymEOh/2s07U+1UGM94y9JEPbk4CjowYgEo3F76ZxYA==} + updates@17.18.0: + resolution: {integrity: sha512-LrTEX4jauR3fMVnni6JtcD1fkbIjxtvJhNXjASZfBFFEaNF3FDWjw6bcenymevduP3zuFortJMIbGFznZeSXtA==} engines: {node: '>=22'} hasBin: true @@ -4835,8 +4835,8 @@ packages: peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - vue-tsc@3.3.3: - resolution: {integrity: sha512-SWUEG7YRUeDJHT7Xsuhf02elYX2gxPzzAII7OxDAh4KNOr4QHQ0Lls0YfnaO5GNd560CwVa2HTfdqmA5MqvRqQ==} + vue-tsc@3.3.4: + resolution: {integrity: sha512-XA/JqmQwS2GZmfgpjOEGdrKwaTSEuPwxpHa7/t6f4yiGrJb3gVHTPb9wBfByMNZwQ+xDXs41b8gaS2DKsOozUw==} hasBin: true peerDependencies: typescript: '>=5.0.0' @@ -5057,14 +5057,14 @@ snapshots: dependencies: '@codemirror/language': 6.12.3 '@codemirror/state': 6.6.0 - '@codemirror/view': 6.43.0 + '@codemirror/view': 6.43.1 '@lezer/common': 1.5.2 '@codemirror/commands@6.10.3': dependencies: '@codemirror/language': 6.12.3 '@codemirror/state': 6.6.0 - '@codemirror/view': 6.43.0 + '@codemirror/view': 6.43.1 '@lezer/common': 1.5.2 '@codemirror/lang-angular@0.1.4': @@ -5104,7 +5104,7 @@ snapshots: '@codemirror/lang-javascript': 6.2.5 '@codemirror/language': 6.12.3 '@codemirror/state': 6.6.0 - '@codemirror/view': 6.43.0 + '@codemirror/view': 6.43.1 '@lezer/common': 1.5.2 '@lezer/css': 1.3.3 '@lezer/html': 1.3.13 @@ -5118,9 +5118,9 @@ snapshots: dependencies: '@codemirror/autocomplete': 6.20.3 '@codemirror/language': 6.12.3 - '@codemirror/lint': 6.9.6 + '@codemirror/lint': 6.9.7 '@codemirror/state': 6.6.0 - '@codemirror/view': 6.43.0 + '@codemirror/view': 6.43.1 '@lezer/common': 1.5.2 '@lezer/javascript': 1.5.4 @@ -5130,7 +5130,7 @@ snapshots: '@codemirror/lang-html': 6.4.11 '@codemirror/language': 6.12.3 '@codemirror/state': 6.6.0 - '@codemirror/view': 6.43.0 + '@codemirror/view': 6.43.1 '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.10 @@ -5154,7 +5154,7 @@ snapshots: '@codemirror/lang-html': 6.4.11 '@codemirror/language': 6.12.3 '@codemirror/state': 6.6.0 - '@codemirror/view': 6.43.0 + '@codemirror/view': 6.43.1 '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.10 @@ -5165,7 +5165,7 @@ snapshots: '@codemirror/lang-html': 6.4.11 '@codemirror/language': 6.12.3 '@codemirror/state': 6.6.0 - '@codemirror/view': 6.43.0 + '@codemirror/view': 6.43.1 '@lezer/common': 1.5.2 '@lezer/markdown': 1.6.4 @@ -5228,7 +5228,7 @@ snapshots: '@codemirror/autocomplete': 6.20.3 '@codemirror/language': 6.12.3 '@codemirror/state': 6.6.0 - '@codemirror/view': 6.43.0 + '@codemirror/view': 6.43.1 '@lezer/common': 1.5.2 '@lezer/xml': 1.0.6 @@ -5271,7 +5271,7 @@ snapshots: '@codemirror/language@6.12.3': dependencies: '@codemirror/state': 6.6.0 - '@codemirror/view': 6.43.0 + '@codemirror/view': 6.43.1 '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.10 @@ -5281,23 +5281,23 @@ snapshots: dependencies: '@codemirror/language': 6.12.3 - '@codemirror/lint@6.9.6': + '@codemirror/lint@6.9.7': dependencies: '@codemirror/state': 6.6.0 - '@codemirror/view': 6.43.0 + '@codemirror/view': 6.43.1 crelt: 1.0.6 '@codemirror/search@6.7.0': dependencies: '@codemirror/state': 6.6.0 - '@codemirror/view': 6.43.0 + '@codemirror/view': 6.43.1 crelt: 1.0.6 '@codemirror/state@6.6.0': dependencies: '@marijn/find-cluster-break': 1.0.2 - '@codemirror/view@6.43.0': + '@codemirror/view@6.43.1': dependencies: '@codemirror/state': 6.6.0 crelt: 1.0.6 @@ -5542,14 +5542,14 @@ snapshots: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 25.9.1 + '@types/node': 25.9.2 jest-mock: 29.7.0 '@jest/fake-timers@29.7.0': dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 25.9.1 + '@types/node': 25.9.2 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -5563,7 +5563,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 25.9.1 + '@types/node': 25.9.2 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -5739,27 +5739,27 @@ snapshots: '@popperjs/core@2.11.8': {} - '@primer/octicons@19.28.0': + '@primer/octicons@19.28.1': dependencies: object-assign: 4.1.1 - '@replit/codemirror-indentation-markers@6.5.3(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)': + '@replit/codemirror-indentation-markers@6.5.3(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.43.1)': dependencies: '@codemirror/language': 6.12.3 '@codemirror/state': 6.6.0 - '@codemirror/view': 6.43.0 + '@codemirror/view': 6.43.1 - '@replit/codemirror-lang-nix@6.0.1(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@lezer/lr@1.4.10)': + '@replit/codemirror-lang-nix@6.0.1(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.43.1)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@lezer/lr@1.4.10)': dependencies: '@codemirror/autocomplete': 6.20.3 '@codemirror/language': 6.12.3 '@codemirror/state': 6.6.0 - '@codemirror/view': 6.43.0 + '@codemirror/view': 6.43.1 '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.10 - '@replit/codemirror-lang-svelte@6.0.0(@codemirror/autocomplete@6.20.3)(@codemirror/lang-css@6.3.1)(@codemirror/lang-html@6.4.11)(@codemirror/lang-javascript@6.2.5)(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@lezer/javascript@1.5.4)(@lezer/lr@1.4.10)': + '@replit/codemirror-lang-svelte@6.0.0(@codemirror/autocomplete@6.20.3)(@codemirror/lang-css@6.3.1)(@codemirror/lang-html@6.4.11)(@codemirror/lang-javascript@6.2.5)(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.43.1)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@lezer/javascript@1.5.4)(@lezer/lr@1.4.10)': dependencies: '@codemirror/autocomplete': 6.20.3 '@codemirror/lang-css': 6.3.1 @@ -5767,21 +5767,21 @@ snapshots: '@codemirror/lang-javascript': 6.2.5 '@codemirror/language': 6.12.3 '@codemirror/state': 6.6.0 - '@codemirror/view': 6.43.0 + '@codemirror/view': 6.43.1 '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/javascript': 1.5.4 '@lezer/lr': 1.4.10 - '@replit/codemirror-vscode-keymap@6.0.2(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.6)(@codemirror/search@6.7.0)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)': + '@replit/codemirror-vscode-keymap@6.0.2(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.0)(@codemirror/state@6.6.0)(@codemirror/view@6.43.1)': dependencies: '@codemirror/autocomplete': 6.20.3 '@codemirror/commands': 6.10.3 '@codemirror/language': 6.12.3 - '@codemirror/lint': 6.9.6 + '@codemirror/lint': 6.9.7 '@codemirror/search': 6.7.0 '@codemirror/state': 6.6.0 - '@codemirror/view': 6.43.0 + '@codemirror/view': 6.43.1 '@resvg/resvg-wasm@2.6.2': {} @@ -5875,14 +5875,14 @@ snapshots: '@stylistic/eslint-plugin@5.10.0(eslint@10.4.1(jiti@2.7.0))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.1(jiti@2.7.0)) - '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/types': 8.61.0 eslint: 10.4.1(jiti@2.7.0) eslint-visitor-keys: 4.2.1 espree: 10.4.0 estraverse: 5.3.0 picomatch: 4.0.4 - '@stylistic/stylelint-plugin@5.2.0(stylelint@17.12.0(typescript@6.0.3))': + '@stylistic/stylelint-plugin@5.2.0(stylelint@17.13.0(typescript@6.0.3))': dependencies: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 @@ -5891,7 +5891,7 @@ snapshots: postcss-selector-parser: 7.1.1 postcss-value-parser: 4.2.0 style-search: 0.1.0 - stylelint: 17.12.0(typescript@6.0.3) + stylelint: 17.13.0(typescript@6.0.3) '@swc/helpers@0.5.23': dependencies: @@ -6054,13 +6054,13 @@ snapshots: dependencies: '@types/istanbul-lib-report': 3.0.3 - '@types/jquery@4.0.0': {} + '@types/jquery@4.0.1': {} '@types/js-yaml@4.0.9': {} '@types/jsdom@20.0.1': dependencies: - '@types/node': 25.9.1 + '@types/node': 25.9.2 '@types/tough-cookie': 4.0.5 parse5: 7.3.0 @@ -6074,7 +6074,7 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@25.9.1': + '@types/node@25.9.2': dependencies: undici-types: 7.24.6 @@ -6105,7 +6105,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 25.9.1 + '@types/node': 25.9.2 '@types/yargs-parser@21.0.3': {} @@ -6113,14 +6113,14 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.60.1(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.60.1 - '@typescript-eslint/type-utils': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/utils': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.60.1 + '@typescript-eslint/parser': 8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/type-utils': 8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.61.0 eslint: 10.4.1(jiti@2.7.0) ignore: 7.0.5 natural-compare: 1.4.0 @@ -6129,14 +6129,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.60.1(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.60.1 - '@typescript-eslint/type-utils': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.60.1 + '@typescript-eslint/parser': 8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/type-utils': 8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.61.0 eslint: 10.4.1(jiti@2.7.0) ignore: 7.0.5 natural-compare: 1.4.0 @@ -6145,66 +6145,66 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/parser@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.60.1 - '@typescript-eslint/types': 8.60.1 - '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.60.1 + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.61.0 debug: 4.4.3 eslint: 10.4.1(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/parser@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.60.1 - '@typescript-eslint/types': 8.60.1 - '@typescript-eslint/typescript-estree': 8.60.1(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.60.1 + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.61.0 debug: 4.4.3 eslint: 10.4.1(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.60.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.61.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) - '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3) + '@typescript-eslint/types': 8.61.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.60.1(typescript@6.0.3)': + '@typescript-eslint/project-service@8.61.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@6.0.3) - '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@6.0.3) + '@typescript-eslint/types': 8.61.0 debug: 4.4.3 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.60.1': + '@typescript-eslint/scope-manager@8.61.0': dependencies: - '@typescript-eslint/types': 8.60.1 - '@typescript-eslint/visitor-keys': 8.60.1 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/visitor-keys': 8.61.0 - '@typescript-eslint/tsconfig-utils@8.60.1(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.61.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.60.1(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.61.0(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.60.1 - '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3) debug: 4.4.3 eslint: 10.4.1(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@5.9.3) @@ -6212,11 +6212,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.60.1 - '@typescript-eslint/typescript-estree': 8.60.1(typescript@6.0.3) - '@typescript-eslint/utils': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) debug: 4.4.3 eslint: 10.4.1(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@6.0.3) @@ -6224,14 +6224,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.60.1': {} + '@typescript-eslint/types@8.61.0': {} - '@typescript-eslint/typescript-estree@8.60.1(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.61.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.60.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) - '@typescript-eslint/types': 8.60.1 - '@typescript-eslint/visitor-keys': 8.60.1 + '@typescript-eslint/project-service': 8.61.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3) + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/visitor-keys': 8.61.0 debug: 4.4.3 minimatch: 10.2.5 semver: 7.8.1 @@ -6241,12 +6241,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.60.1(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.61.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.60.1(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@6.0.3) - '@typescript-eslint/types': 8.60.1 - '@typescript-eslint/visitor-keys': 8.60.1 + '@typescript-eslint/project-service': 8.61.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@6.0.3) + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/visitor-keys': 8.61.0 debug: 4.4.3 minimatch: 10.2.5 semver: 7.8.1 @@ -6256,31 +6256,31 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/utils@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.1(jiti@2.7.0)) - '@typescript-eslint/scope-manager': 8.60.1 - '@typescript-eslint/types': 8.60.1 - '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) eslint: 10.4.1(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/utils@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.1(jiti@2.7.0)) - '@typescript-eslint/scope-manager': 8.60.1 - '@typescript-eslint/types': 8.60.1 - '@typescript-eslint/typescript-estree': 8.60.1(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) eslint: 10.4.1(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.60.1': + '@typescript-eslint/visitor-keys@8.61.0': dependencies: - '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/types': 8.61.0 eslint-visitor-keys: 5.0.1 '@unrs/resolver-binding-android-arm-eabi@1.12.2': @@ -6358,21 +6358,21 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - '@vitejs/plugin-vue@6.0.7(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0))(vue@3.5.35(typescript@6.0.3))': + '@vitejs/plugin-vue@6.0.7(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0))(vue@3.5.35(typescript@6.0.3))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0) + vite: 8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0) vue: 3.5.35(typescript@6.0.3) - '@vitest/eslint-plugin@1.6.19(@typescript-eslint/eslint-plugin@8.60.1(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.8(@types/node@25.9.1)(happy-dom@20.10.1)(jsdom@20.0.3)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)))': + '@vitest/eslint-plugin@1.6.20(@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.8(@types/node@25.9.2)(happy-dom@20.10.2)(jsdom@20.0.3)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)))': dependencies: - '@typescript-eslint/scope-manager': 8.60.1 - '@typescript-eslint/utils': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/utils': 8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) eslint: 10.4.1(jiti@2.7.0) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.60.1(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) typescript: 6.0.3 - vitest: 4.1.8(@types/node@25.9.1)(happy-dom@20.10.1)(jsdom@20.0.3)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)) + vitest: 4.1.8(@types/node@25.9.2)(happy-dom@20.10.2)(jsdom@20.0.3)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)) transitivePeerDependencies: - supports-color @@ -6385,13 +6385,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0))': + '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0))': dependencies: '@vitest/spy': 4.1.8 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0) + vite: 8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0) '@vitest/pretty-format@4.1.8': dependencies: @@ -6459,7 +6459,7 @@ snapshots: '@vue/compiler-dom': 3.5.35 '@vue/shared': 3.5.35 - '@vue/language-core@3.3.3': + '@vue/language-core@3.3.4': dependencies: '@volar/language-core': 2.4.28 '@vue/compiler-dom': 3.5.35 @@ -6700,7 +6700,7 @@ snapshots: buffer-image-size@0.6.4: dependencies: - '@types/node': 25.9.1 + '@types/node': 25.9.2 buffer@5.7.1: dependencies: @@ -7435,7 +7435,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@4.4.5(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.4.1(jiti@2.7.0)))(eslint-plugin-import@2.32.0)(eslint@10.4.1(jiti@2.7.0)): + eslint-import-resolver-typescript@4.4.5(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.4.1(jiti@2.7.0)))(eslint-plugin-import@2.32.0)(eslint@10.4.1(jiti@2.7.0)): dependencies: debug: 4.4.3 eslint: 10.4.1(jiti@2.7.0) @@ -7446,30 +7446,30 @@ snapshots: tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@4.4.5)(eslint@10.4.1(jiti@2.7.0)) - eslint-plugin-import-x: 4.16.2(@typescript-eslint/utils@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.4.1(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@4.4.5)(eslint@10.4.1(jiti@2.7.0)) + eslint-plugin-import-x: 4.16.2(@typescript-eslint/utils@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.4.1(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@4.4.5)(eslint@10.4.1(jiti@2.7.0)): + eslint-module-utils@2.13.0(@typescript-eslint/parser@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@4.4.5)(eslint@10.4.1(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3) eslint: 10.4.1(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 4.4.5(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.4.1(jiti@2.7.0)))(eslint-plugin-import@2.32.0)(eslint@10.4.1(jiti@2.7.0)) + eslint-import-resolver-typescript: 4.4.5(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.4.1(jiti@2.7.0)))(eslint-plugin-import@2.32.0)(eslint@10.4.1(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@4.4.5)(eslint@10.4.1(jiti@2.7.0)): + eslint-module-utils@2.13.0(@typescript-eslint/parser@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@4.4.5)(eslint@10.4.1(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) eslint: 10.4.1(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 4.4.5(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.4.1(jiti@2.7.0)))(eslint-plugin-import@2.32.0)(eslint@10.4.1(jiti@2.7.0)) + eslint-import-resolver-typescript: 4.4.5(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.4.1(jiti@2.7.0)))(eslint-plugin-import@2.32.0)(eslint@10.4.1(jiti@2.7.0)) transitivePeerDependencies: - supports-color optional: true @@ -7507,8 +7507,8 @@ snapshots: '@eslint/eslintrc': 3.3.5 '@eslint/js': 9.39.4 '@github/browserslist-config': 1.0.0 - '@typescript-eslint/eslint-plugin': 8.60.1(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/parser': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3) aria-query: 5.3.2 eslint: 10.4.1(jiti@2.7.0) eslint-config-prettier: 10.1.8(eslint@10.4.1(jiti@2.7.0)) @@ -7516,7 +7516,7 @@ snapshots: eslint-plugin-eslint-comments: 3.2.0(eslint@10.4.1(jiti@2.7.0)) eslint-plugin-filenames: 1.3.2(eslint@10.4.1(jiti@2.7.0)) eslint-plugin-i18n-text: 1.0.1(eslint@10.4.1(jiti@2.7.0)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.5)(eslint@10.4.1(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.5)(eslint@10.4.1(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@10.4.1(jiti@2.7.0)) eslint-plugin-no-only-tests: 3.4.0 eslint-plugin-prettier: 5.5.6(eslint-config-prettier@10.1.8(eslint@10.4.1(jiti@2.7.0)))(eslint@10.4.1(jiti@2.7.0))(prettier@3.8.3) @@ -7526,7 +7526,7 @@ snapshots: prettier: 3.8.3 svg-element-attributes: 1.3.1 typescript: 5.9.3 - typescript-eslint: 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3) + typescript-eslint: 8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3) transitivePeerDependencies: - '@types/eslint' - eslint-import-resolver-typescript @@ -7537,10 +7537,10 @@ snapshots: dependencies: eslint: 10.4.1(jiti@2.7.0) - eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.4.1(jiti@2.7.0)): + eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.4.1(jiti@2.7.0)): dependencies: '@package-json/types': 0.0.12 - '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/types': 8.61.0 comment-parser: 1.4.7 debug: 4.4.3 eslint: 10.4.1(jiti@2.7.0) @@ -7551,12 +7551,12 @@ snapshots: stable-hash-x: 0.2.0 unrs-resolver: 1.12.2 optionalDependencies: - '@typescript-eslint/utils': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) eslint-import-resolver-node: 0.3.10 transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.5)(eslint@10.4.1(jiti@2.7.0)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.5)(eslint@10.4.1(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -7567,7 +7567,7 @@ snapshots: doctrine: 2.1.0 eslint: 10.4.1(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@4.4.5)(eslint@10.4.1(jiti@2.7.0)) + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@4.4.5)(eslint@10.4.1(jiti@2.7.0)) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -7579,13 +7579,13 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@4.4.5)(eslint@10.4.1(jiti@2.7.0)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@4.4.5)(eslint@10.4.1(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -7596,7 +7596,7 @@ snapshots: doctrine: 2.1.0 eslint: 10.4.1(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@4.4.5)(eslint@10.4.1(jiti@2.7.0)) + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@4.4.5)(eslint@10.4.1(jiti@2.7.0)) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -7608,7 +7608,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -7707,7 +7707,7 @@ snapshots: postcss-selector-parser: 7.1.1 vue-eslint-parser: 10.4.0(eslint@10.4.1(jiti@2.7.0)) - eslint-plugin-vue@10.9.2(@stylistic/eslint-plugin@5.10.0(eslint@10.4.1(jiti@2.7.0)))(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(vue-eslint-parser@10.4.0(eslint@10.4.1(jiti@2.7.0))): + eslint-plugin-vue@10.9.2(@stylistic/eslint-plugin@5.10.0(eslint@10.4.1(jiti@2.7.0)))(@typescript-eslint/parser@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(vue-eslint-parser@10.4.0(eslint@10.4.1(jiti@2.7.0))): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.1(jiti@2.7.0)) eslint: 10.4.1(jiti@2.7.0) @@ -7719,7 +7719,7 @@ snapshots: xml-name-validator: 4.0.0 optionalDependencies: '@stylistic/eslint-plugin': 5.10.0(eslint@10.4.1(jiti@2.7.0)) - '@typescript-eslint/parser': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) eslint-plugin-wc@3.1.0(eslint@10.4.1(jiti@2.7.0)): dependencies: @@ -8047,9 +8047,9 @@ snapshots: hammerjs@2.0.8: {} - happy-dom@20.10.1: + happy-dom@20.10.2: dependencies: - '@types/node': 25.9.1 + '@types/node': 25.9.2 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 buffer-image-size: 0.6.4 @@ -8365,7 +8365,7 @@ snapshots: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 '@types/jsdom': 20.0.1 - '@types/node': 25.9.1 + '@types/node': 25.9.2 jest-mock: 29.7.0 jest-util: 29.7.0 jsdom: 20.0.3 @@ -8389,13 +8389,13 @@ snapshots: jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 25.9.1 + '@types/node': 25.9.2 jest-util: 29.7.0 jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 25.9.1 + '@types/node': 25.9.2 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -9631,25 +9631,25 @@ snapshots: style-search@0.1.0: {} - stylelint-config-recommended@18.0.0(stylelint@17.12.0(typescript@6.0.3)): + stylelint-config-recommended@18.0.0(stylelint@17.13.0(typescript@6.0.3)): dependencies: - stylelint: 17.12.0(typescript@6.0.3) + stylelint: 17.13.0(typescript@6.0.3) - stylelint-declaration-block-no-ignored-properties@3.0.0(stylelint@17.12.0(typescript@6.0.3)): + stylelint-declaration-block-no-ignored-properties@3.0.0(stylelint@17.13.0(typescript@6.0.3)): dependencies: - stylelint: 17.12.0(typescript@6.0.3) + stylelint: 17.13.0(typescript@6.0.3) - stylelint-declaration-strict-value@1.11.1(stylelint@17.12.0(typescript@6.0.3)): + stylelint-declaration-strict-value@1.11.1(stylelint@17.13.0(typescript@6.0.3)): dependencies: - stylelint: 17.12.0(typescript@6.0.3) + stylelint: 17.13.0(typescript@6.0.3) - stylelint-value-no-unknown-custom-properties@6.1.1(stylelint@17.12.0(typescript@6.0.3)): + stylelint-value-no-unknown-custom-properties@6.1.1(stylelint@17.13.0(typescript@6.0.3)): dependencies: postcss-value-parser: 4.2.0 resolve: 1.22.12 - stylelint: 17.12.0(typescript@6.0.3) + stylelint: 17.13.0(typescript@6.0.3) - stylelint@17.12.0(typescript@6.0.3): + stylelint@17.13.0(typescript@6.0.3): dependencies: '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) @@ -9910,23 +9910,23 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3): + typescript-eslint@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.60.1(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/parser': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3) eslint: 10.4.1(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color - typescript-eslint@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3): + typescript-eslint@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.60.1(@typescript-eslint/parser@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/parser': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.60.1(typescript@6.0.3) - '@typescript-eslint/utils': 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.0(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) eslint: 10.4.1(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: @@ -9990,7 +9990,7 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - updates@17.17.3: {} + updates@17.18.0: {} uri-js@4.4.1: dependencies: @@ -10019,11 +10019,11 @@ snapshots: core-util-is: 1.0.2 extsprintf: 1.3.0 - vite-string-plugin@2.0.4(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)): + vite-string-plugin@2.0.4(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)): dependencies: - vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0) + vite: 8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0) - vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0): + vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -10031,15 +10031,15 @@ snapshots: rolldown: 1.0.3 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 25.9.1 + '@types/node': 25.9.2 esbuild: 0.28.1 fsevents: 2.3.3 jiti: 2.7.0 - vitest@4.1.8(@types/node@25.9.1)(happy-dom@20.10.1)(jsdom@20.0.3)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)): + vitest@4.1.8(@types/node@25.9.2)(happy-dom@20.10.2)(jsdom@20.0.3)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)): dependencies: '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)) + '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)) '@vitest/pretty-format': 4.1.8 '@vitest/runner': 4.1.8 '@vitest/snapshot': 4.1.8 @@ -10056,11 +10056,11 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0) + vite: 8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 25.9.1 - happy-dom: 20.10.1 + '@types/node': 25.9.2 + happy-dom: 20.10.2 jsdom: 20.0.3 transitivePeerDependencies: - msw @@ -10090,10 +10090,10 @@ snapshots: transitivePeerDependencies: - supports-color - vue-tsc@3.3.3(typescript@6.0.3): + vue-tsc@3.3.4(typescript@6.0.3): dependencies: '@volar/typescript': 2.4.28 - '@vue/language-core': 3.3.3 + '@vue/language-core': 3.3.4 typescript: 6.0.3 vue@3.5.35(typescript@6.0.3): diff --git a/public/assets/img/svg/octicon-stack-add.svg b/public/assets/img/svg/octicon-stack-add.svg new file mode 100644 index 0000000000..5a63dce06c --- /dev/null +++ b/public/assets/img/svg/octicon-stack-add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-stack-check.svg b/public/assets/img/svg/octicon-stack-check.svg index 5d35f04ad6..a97f7f0bc6 100644 --- a/public/assets/img/svg/octicon-stack-check.svg +++ b/public/assets/img/svg/octicon-stack-check.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-stack-remove.svg b/public/assets/img/svg/octicon-stack-remove.svg index 0cba43490f..c52352d881 100644 --- a/public/assets/img/svg/octicon-stack-remove.svg +++ b/public/assets/img/svg/octicon-stack-remove.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index d62c8421e7..631d0a5052 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ requires-python = ">=3.10" [dependency-groups] dev = [ - "djlint==1.36.4", + "djlint==1.39.0", "yamllint==1.38.0", "zizmor==1.25.2", ] diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 7273614dc7..caa4343d4a 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -88,6 +88,7 @@ import ( "gitea.dev/routers/api/v1/packages" "gitea.dev/routers/api/v1/repo" "gitea.dev/routers/api/v1/settings" + "gitea.dev/routers/api/v1/token" "gitea.dev/routers/api/v1/user" "gitea.dev/routers/common" "gitea.dev/services/actions" @@ -504,41 +505,79 @@ func reqOrgOwnership() func(ctx *context.APIContext) { } } -// reqTeamMembership user should be an team member, or a site admin -func reqTeamMembership() func(ctx *context.APIContext) { +func teamAccessPrivileged(ctx *context.APIContext) (orgID int64, privileged, ok bool) { + if ctx.IsUserSiteAdmin() { + return 0, true, true + } + if ctx.Org.Team == nil { + setting.PanicInDevOrTesting("teamAccess: unprepared context") + ctx.APIErrorInternal(errors.New("teamAccess: unprepared context")) + return 0, false, false + } + + orgID = ctx.Org.Team.OrgID + isOwner, err := organization.IsOrganizationOwner(ctx, orgID, ctx.Doer.ID) + if err != nil { + ctx.APIErrorInternal(err) + return 0, false, false + } else if isOwner { + return orgID, true, true + } + + isTeamMember, err := organization.IsTeamMember(ctx, orgID, ctx.Org.Team.ID, ctx.Doer.ID) + if err != nil { + ctx.APIErrorInternal(err) + return 0, false, false + } + return orgID, isTeamMember, true +} + +func denyNonTeamMember(ctx *context.APIContext, orgID int64) { + isOrgMember, err := organization.IsOrganizationMember(ctx, orgID, ctx.Doer.ID) + if err != nil { + ctx.APIErrorInternal(err) + } else if isOrgMember { + ctx.APIError(http.StatusForbidden, "Must be a team member") + } else { + ctx.APIErrorNotFound() + } +} + +// reqTeamReadAccess allows callers who can list the team to read its metadata. +// Non-members are admitted by the team's visibility tier and parent org visibility. +// Not sufficient for mutations — use reqOrgOwnership() or reqTeamMembership() for those. +func reqTeamReadAccess() func(ctx *context.APIContext) { return func(ctx *context.APIContext) { - if ctx.IsUserSiteAdmin() { + orgID, privileged, ok := teamAccessPrivileged(ctx) + if !ok || privileged { return } - if ctx.Org.Team == nil { - setting.PanicInDevOrTesting("reqTeamMembership: unprepared context") - ctx.APIErrorInternal(errors.New("reqTeamMembership: unprepared context")) + if ctx.Org.Organization == nil { + setting.PanicInDevOrTesting("reqTeamReadAccess: organization not loaded") + ctx.APIErrorInternal(errors.New("reqTeamReadAccess: organization not loaded")) return } - orgID := ctx.Org.Team.OrgID - isOwner, err := organization.IsOrganizationOwner(ctx, orgID, ctx.Doer.ID) + visible, err := ctx.Org.Team.CanNonMemberReadMeta(ctx, ctx.Org.Organization.AsUser(), ctx.Doer) if err != nil { ctx.APIErrorInternal(err) return - } else if isOwner { - return } + if !visible { + // Not admitted by visibility: 403 for org members, 404 otherwise. + denyNonTeamMember(ctx, orgID) + } + } +} - if isTeamMember, err := organization.IsTeamMember(ctx, orgID, ctx.Org.Team.ID, ctx.Doer.ID); err != nil { - ctx.APIErrorInternal(err) - return - } else if !isTeamMember { - isOrgMember, err := organization.IsOrganizationMember(ctx, orgID, ctx.Doer.ID) - if err != nil { - ctx.APIErrorInternal(err) - } else if isOrgMember { - ctx.APIError(http.StatusForbidden, "Must be a team member") - } else { - ctx.APIErrorNotFound() - } +// reqTeamMembership user should be a team member, or a site admin +func reqTeamMembership() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + orgID, privileged, ok := teamAccessPrivileged(ctx) + if !ok || privileged { return } + denyNonTeamMember(ctx, orgID) } } @@ -648,6 +687,17 @@ func orgAssignment(args ...bool) func(ctx *context.APIContext) { } return } + if ctx.Org.Organization == nil { + ctx.Org.Organization, err = organization.GetOrgByID(ctx, ctx.Org.Team.OrgID) + if err != nil { + if organization.IsErrOrgNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } + } } } } @@ -976,6 +1026,11 @@ func Routes() *web.Router { }) }) + // Token introspection and deletion endpoint + m.Combo("/token"). + Get(reqToken(), token.GetCurrentToken). + Delete(reqToken(), token.DeleteCurrentToken) + // Notifications (requires 'notifications' scope) // The notifications API is not available for public-only tokens because a user's notifications mix // public and private repository events in the same mailbox. @@ -1707,25 +1762,31 @@ func Routes() *web.Router { }) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly()) m.Group("/teams/{teamid}", func() { - m.Combo("").Get(reqToken(), org.GetTeam). - Patch(reqToken(), reqOrgOwnership(), bind(api.EditTeamOption{}), org.EditTeam). + m.Combo("").Patch(reqToken(), reqOrgOwnership(), bind(api.EditTeamOption{}), org.EditTeam). Delete(reqToken(), reqOrgOwnership(), org.DeleteTeam) + m.Group("", func() { + m.Get("", org.GetTeam) + m.Group("/members", func() { + m.Get("", reqOrgMembership(), org.GetTeamMembers) + m.Combo("/{username}").Get(reqOrgMembership(), org.GetTeamMember) + }) + m.Group("/repos", func() { + m.Get("", org.GetTeamRepos) + m.Combo("/{org}/{reponame}").Get(org.GetTeamRepo) + }) + m.Get("/activities/feeds", org.ListTeamActivityFeeds) + }, reqTeamReadAccess()) m.Group("/members", func() { - m.Get("", reqToken(), org.GetTeamMembers) m.Combo("/{username}"). - Get(reqToken(), org.GetTeamMember). Put(reqToken(), reqOrgOwnership(), org.AddTeamMember). Delete(reqToken(), reqOrgOwnership(), org.RemoveTeamMember) }) m.Group("/repos", func() { - m.Get("", reqToken(), org.GetTeamRepos) m.Combo("/{org}/{reponame}"). - Put(reqToken(), org.AddTeamRepository). - Delete(reqToken(), org.RemoveTeamRepository). - Get(reqToken(), org.GetTeamRepo) + Put(reqToken(), reqTeamMembership(), org.AddTeamRepository). + Delete(reqToken(), reqTeamMembership(), org.RemoveTeamRepository) }) - m.Get("/activities/feeds", org.ListTeamActivityFeeds) - }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamMembership(), checkTokenPublicOnly()) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), checkTokenPublicOnly()) m.Group("/admin", func() { m.Group("/cron", func() { diff --git a/routers/api/v1/org/team.go b/routers/api/v1/org/team.go index 4373b90f2f..bc6bb54e90 100644 --- a/routers/api/v1/org/team.go +++ b/routers/api/v1/org/team.go @@ -55,10 +55,15 @@ func ListTeams(ctx *context.APIContext) { // "$ref": "#/responses/notFound" listOptions := utils.GetListOptions(ctx) - teams, count, err := organization.SearchTeam(ctx, &organization.SearchTeamOptions{ + opts := &organization.SearchTeamOptions{ ListOptions: listOptions, OrgID: ctx.Org.Organization.ID, - }) + } + if err := organization.ApplyTeamListFilter(ctx, ctx.Org.Organization.ID, ctx.Doer, ctx.IsSigned, opts); err != nil { + ctx.APIErrorInternal(err) + return + } + teams, count, err := organization.SearchTeam(ctx, opts) if err != nil { ctx.APIErrorInternal(err) return @@ -218,6 +223,7 @@ func CreateTeam(ctx *context.APIContext) { IncludesAllRepositories: form.IncludesAllRepositories, CanCreateOrgRepo: form.CanCreateOrgRepo, AccessMode: teamPermission, + Visibility: organization.NormalizeTeamVisibility(string(form.Visibility)), } if team.AccessMode < perm.AccessModeAdmin { @@ -295,6 +301,10 @@ func EditTeam(ctx *context.APIContext) { team.Description = *form.Description } + if form.Visibility != nil && !team.IsOwnerTeam() { + team.Visibility = organization.NormalizeTeamVisibility(string(*form.Visibility)) + } + isAuthChanged := false isIncludeAllChanged := false if !team.IsOwnerTeam() && len(form.Permission) != 0 { @@ -387,15 +397,6 @@ func GetTeamMembers(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - isMember, err := organization.IsOrganizationMember(ctx, ctx.Org.Team.OrgID, ctx.Doer.ID) - if err != nil { - ctx.APIErrorInternal(err) - return - } else if !isMember && !ctx.Doer.IsAdmin { - ctx.APIErrorNotFound() - return - } - listOptions := utils.GetListOptions(ctx) teamMembers, err := organization.GetTeamMembers(ctx, &organization.SearchMembersOptions{ ListOptions: listOptions, @@ -574,14 +575,20 @@ func GetTeamRepos(ctx *context.APIContext) { ctx.APIErrorInternal(err) return } - repos := make([]*api.Repository, len(teamRepos)) - for i, repo := range teamRepos { + repos := make([]*api.Repository, 0, len(teamRepos)) + for _, repo := range teamRepos { permission, err := access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer) if err != nil { ctx.APIErrorInternal(err) return } - repos[i] = convert.ToRepo(ctx, repo, permission) + // A team's repo list is reachable by non-team-members through the team's + // visibility tier, so never expose repos (incl. their names) the doer + // cannot access. + if !permission.HasAnyUnitAccessOrPublicAccess() { + continue + } + repos = append(repos, convert.ToRepo(ctx, repo, permission)) } ctx.SetLinkHeader(int64(team.NumRepos), listOptions.PageSize) ctx.SetTotalCountHeader(int64(team.NumRepos)) @@ -633,6 +640,12 @@ func GetTeamRepo(ctx *context.APIContext) { ctx.APIErrorInternal(err) return } + // The team may be reachable by a non-team-member via its visibility tier; + // don't confirm the existence of a repo the doer cannot access. + if !permission.HasAnyUnitAccessOrPublicAccess() { + ctx.APIErrorNotFound() + return + } ctx.JSON(http.StatusOK, convert.ToRepo(ctx, repo, permission)) } @@ -806,9 +819,9 @@ func SearchTeam(ctx *context.APIContext) { ListOptions: listOptions, } - // Only admin is allowed to search for all teams - if !ctx.Doer.IsAdmin { - opts.UserID = ctx.Doer.ID + if err := organization.ApplyTeamListFilter(ctx, ctx.Org.Organization.ID, ctx.Doer, ctx.IsSigned, opts); err != nil { + ctx.APIErrorInternal(err) + return } teams, maxResults, err := organization.SearchTeam(ctx, opts) diff --git a/routers/api/v1/repo/compare.go b/routers/api/v1/repo/compare.go index 081ea5e91b..ddd5a9bb21 100644 --- a/routers/api/v1/repo/compare.go +++ b/routers/api/v1/repo/compare.go @@ -11,6 +11,7 @@ import ( api "gitea.dev/modules/structs" "gitea.dev/services/context" "gitea.dev/services/convert" + git_service "gitea.dev/services/git" ) // CompareDiff compare two branches or commits @@ -18,8 +19,12 @@ func CompareDiff(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/compare/{basehead} repository repoCompareDiff // --- // summary: Get commit comparison information + // description: | + // By default returns JSON commit comparison information. The raw diff or patch can be + // requested with the `output` query parameter set to `diff` or `patch` respectively. // produces: // - application/json + // - text/plain // parameters: // - name: owner // in: path @@ -33,9 +38,16 @@ func CompareDiff(ctx *context.APIContext) { // required: true // - name: basehead // in: path - // description: compare two branches or commits + // description: compare two refs as `base...head` (or `base..head`); refs may be branches, tags, full or short SHAs, including branch names that contain slashes. // type: string // required: true + // - name: output + // in: query + // description: return the raw comparison as `diff` or `patch` instead of JSON + // type: string + // enum: + // - diff + // - patch // responses: // "200": // "$ref": "#/responses/Compare" @@ -57,6 +69,16 @@ func CompareDiff(ctx *context.APIContext) { } defer closer() + // ?output=diff|patch returns the raw output, otherwise the JSON comparison is returned. + switch ctx.FormString("output") { + case "diff": + downloadCompareDiffOrPatch(ctx, compareInfo, false) + return + case "patch": + downloadCompareDiffOrPatch(ctx, compareInfo, true) + return + } + verification := ctx.FormString("verification") == "" || ctx.FormBool("verification") files := ctx.FormString("files") == "" || ctx.FormBool("files") @@ -88,3 +110,20 @@ func CompareDiff(ctx *context.APIContext) { Commits: apiCommits, }) } + +// downloadCompareDiffOrPatch writes a comparison's raw diff or patch to the response. +func downloadCompareDiffOrPatch(ctx *context.APIContext, compareInfo *git_service.CompareInfo, patch bool) { + ctx.Resp.Header().Set("Content-Type", "text/plain; charset=utf-8") + compareArg := compareInfo.BaseCommitID + compareInfo.CompareSeparator + compareInfo.HeadCommitID + + var err error + if patch { + err = compareInfo.HeadGitRepo.GetPatch(compareArg, ctx.Resp) + } else { + err = compareInfo.HeadGitRepo.GetDiff(compareArg, ctx.Resp) + } + if err != nil { + ctx.APIErrorInternal(err) + return + } +} diff --git a/routers/api/v1/repo/issue_tracked_time.go b/routers/api/v1/repo/issue_tracked_time.go index 33af841fbd..ff723e679f 100644 --- a/routers/api/v1/repo/issue_tracked_time.go +++ b/routers/api/v1/repo/issue_tracked_time.go @@ -91,6 +91,7 @@ func ListTrackedTimes(ctx *context.APIContext) { user, err := user_model.GetUserByName(ctx, qUser) if user_model.IsErrUserNotExist(err) { ctx.APIError(http.StatusNotFound, err.Error()) + return } else if err != nil { ctx.APIErrorInternal(err) return @@ -499,6 +500,7 @@ func ListTrackedTimesByRepository(ctx *context.APIContext) { user, err := user_model.GetUserByName(ctx, qUser) if user_model.IsErrUserNotExist(err) { ctx.APIError(http.StatusNotFound, err.Error()) + return } else if err != nil { ctx.APIErrorInternal(err) return diff --git a/routers/api/v1/swagger/app.go b/routers/api/v1/swagger/app.go index dc30cda699..3097035e45 100644 --- a/routers/api/v1/swagger/app.go +++ b/routers/api/v1/swagger/app.go @@ -20,3 +20,10 @@ type swaggerResponseAccessToken struct { // in:body Body api.AccessToken `json:"body"` } + +// CurrentAccessToken represents the currently authenticated access token. +// swagger:response CurrentAccessToken +type swaggerResponseCurrentAccessToken struct { + // in:body + Body api.CurrentAccessToken `json:"body"` +} diff --git a/routers/api/v1/token/token.go b/routers/api/v1/token/token.go new file mode 100644 index 0000000000..7712a7c8c2 --- /dev/null +++ b/routers/api/v1/token/token.go @@ -0,0 +1,88 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package token + +import ( + "errors" + "net/http" + + auth_model "gitea.dev/models/auth" + user_model "gitea.dev/models/user" + "gitea.dev/modules/auth/httpauth" + api "gitea.dev/modules/structs" + "gitea.dev/modules/util" + "gitea.dev/services/context" +) + +// GetCurrentToken returns metadata about the currently authenticated token. +func GetCurrentToken(ctx *context.APIContext) { + // swagger:operation GET /token miscellaneous getCurrentToken + // --- + // summary: Get the currently authenticated token + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/CurrentAccessToken" + accessToken, err := getToken(ctx) + if err != nil { + ctx.APIErrorAuto(err) + return + } + + // Get user info + user, err := user_model.GetUserByID(ctx, accessToken.UID) + if err != nil { + ctx.APIErrorAuto(err) + return + } + + ctx.JSON(http.StatusOK, &api.CurrentAccessToken{ + ID: accessToken.ID, + Name: accessToken.Name, + Scopes: accessToken.Scope.StringSlice(), + CreatedAt: accessToken.CreatedUnix.AsTime(), + LastUsedAt: accessToken.UpdatedUnix.AsTime(), + User: &api.UserMeta{ + ID: user.ID, + Login: user.Name, + }, + }) +} + +// DeleteCurrentToken deletes the currently authenticated token. +func DeleteCurrentToken(ctx *context.APIContext) { + // swagger:operation DELETE /token miscellaneous deleteCurrentToken + // --- + // summary: Delete the currently authenticated token + // produces: + // - application/json + // responses: + // "204": + // description: token deleted + accessToken, err := getToken(ctx) + if err != nil { + ctx.APIErrorAuto(err) + return + } + + // Delete the token + err = auth_model.DeleteAccessTokenByID(ctx, accessToken.ID, accessToken.UID) + if err != nil && !errors.Is(err, util.ErrNotExist) { + ctx.APIErrorAuto(err) + return + } + ctx.Status(http.StatusNoContent) +} + +// getToken retrieves an access token from the API context's Authorization header and validates it against the database. +// Returns nil if the token is invalid and handles the response +func getToken(ctx *context.APIContext) (*auth_model.AccessToken, error) { + authHeader := ctx.Req.Header.Get("Authorization") + parsed, ok := httpauth.ParseAuthorizationHeader(authHeader) + if !ok || parsed.BearerToken == nil { + return nil, util.NewNotExistErrorf("invalid access token") + } + return auth_model.GetAccessTokenBySHA(ctx, parsed.BearerToken.Token) +} diff --git a/routers/api/v1/user/app.go b/routers/api/v1/user/app.go index a410909e0e..87aef1d10d 100644 --- a/routers/api/v1/user/app.go +++ b/routers/api/v1/user/app.go @@ -191,17 +191,9 @@ func DeleteAccessToken(ctx *context.APIContext) { return } } - if tokenID == 0 { - ctx.APIErrorInternal(nil) - return - } if err := auth_model.DeleteAccessTokenByID(ctx, tokenID, ctx.ContextUser.ID); err != nil { - if auth_model.IsErrAccessTokenNotExist(err) { - ctx.APIErrorNotFound() - } else { - ctx.APIErrorInternal(err) - } + ctx.APIErrorAuto(err) return } diff --git a/routers/web/admin/orgs.go b/routers/web/admin/orgs.go index 1474038c91..02037af89f 100644 --- a/routers/web/admin/orgs.go +++ b/routers/web/admin/orgs.go @@ -23,10 +23,7 @@ func Organizations(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("admin.organizations") ctx.Data["PageIsAdminOrganizations"] = true - if ctx.FormString("sort") == "" { - ctx.SetFormString("sort", UserSearchDefaultAdminSort) - } - + sortOrder := ctx.FormString("sort", UserSearchDefaultAdminSort) explore.RenderUserSearch(ctx, user_model.SearchUserOptions{ Actor: ctx.Doer, Types: []user_model.UserType{user_model.UserTypeOrganization}, @@ -35,5 +32,6 @@ func Organizations(ctx *context.Context) { PageSize: setting.UI.Admin.OrgPagingNum, }, Visible: []structs.VisibleType{structs.VisibleTypePublic, structs.VisibleTypeLimited, structs.VisibleTypePrivate}, + OrderBy: db.SearchOrderBy(sortOrder), }, tplOrgs) } diff --git a/routers/web/admin/users.go b/routers/web/admin/users.go index 4b34594508..f918c8b5d3 100644 --- a/routers/web/admin/users.go +++ b/routers/web/admin/users.go @@ -55,11 +55,7 @@ func Users(ctx *context.Context) { statusFilterMap[filterKey] = paramVal } - sortType := ctx.FormString("sort") - if sortType == "" { - sortType = UserSearchDefaultAdminSort - ctx.SetFormString("sort", sortType) - } + sortType := ctx.FormString("sort", UserSearchDefaultAdminSort) ctx.PageData["adminUserListSearchForm"] = map[string]any{ "StatusFilterMap": statusFilterMap, "SortType": sortType, @@ -78,6 +74,7 @@ func Users(ctx *context.Context) { IsTwoFactorEnabled: optional.ParseBool(statusFilterMap["is_2fa_enabled"]), IsProhibitLogin: optional.ParseBool(statusFilterMap["is_prohibit_login"]), IncludeReserved: true, // administrator needs to list all accounts include reserved, bot, remote ones + OrderBy: db.SearchOrderBy(sortType), }, tplUsers) } diff --git a/routers/web/explore/org.go b/routers/web/explore/org.go index 621f6bd97a..687d83ff36 100644 --- a/routers/web/explore/org.go +++ b/routers/web/explore/org.go @@ -38,17 +38,14 @@ func Organizations(ctx *context.Context) { "alphabetically", "reversealphabetically", ) - sortOrder := ctx.FormString("sort") - if sortOrder == "" { - sortOrder = util.Iif(supportedSortOrders.Contains(setting.UI.ExploreDefaultSort), setting.UI.ExploreDefaultSort, "newest") - ctx.SetFormString("sort", sortOrder) - } - + sortOrderDefault := util.Iif(supportedSortOrders.Contains(setting.UI.ExploreDefaultSort), setting.UI.ExploreDefaultSort, "newest") + sortOrder := ctx.FormString("sort", sortOrderDefault) RenderUserSearch(ctx, user_model.SearchUserOptions{ Actor: ctx.Doer, Types: []user_model.UserType{user_model.UserTypeOrganization}, ListOptions: db.ListOptions{PageSize: setting.UI.ExplorePagingNum}, Visible: visibleTypes, + OrderBy: db.SearchOrderBy(sortOrder), SupportedSortOrders: supportedSortOrders, }, tplExploreUsers) diff --git a/routers/web/explore/user.go b/routers/web/explore/user.go index 00217d662c..64e2a92d68 100644 --- a/routers/web/explore/user.go +++ b/routers/web/explore/user.go @@ -55,11 +55,7 @@ func RenderUserSearch(ctx *context.Context, opts user_model.SearchUserOptions, t ) // we can not set orderBy to `models.SearchOrderByXxx`, because there may be a JOIN in the statement, different tables may have the same name columns - - sortOrder := ctx.FormString("sort") - if sortOrder == "" { - sortOrder = setting.UI.ExploreDefaultSort - } + sortOrder := util.IfZero(string(opts.OrderBy), ctx.FormString("sort", setting.UI.ExploreDefaultSort)) ctx.Data["SortType"] = sortOrder switch sortOrder { @@ -145,18 +141,15 @@ func Users(ctx *context.Context) { "alphabetically", "reversealphabetically", ) - sortOrder := ctx.FormString("sort") - if sortOrder == "" { - sortOrder = util.Iif(supportedSortOrders.Contains(setting.UI.ExploreDefaultSort), setting.UI.ExploreDefaultSort, "newest") - ctx.SetFormString("sort", sortOrder) - } - + sortOrderDefault := util.Iif(supportedSortOrders.Contains(setting.UI.ExploreDefaultSort), setting.UI.ExploreDefaultSort, "newest") + sortOrder := ctx.FormString("sort", sortOrderDefault) RenderUserSearch(ctx, user_model.SearchUserOptions{ Actor: ctx.Doer, Types: []user_model.UserType{user_model.UserTypeIndividual}, ListOptions: db.ListOptions{PageSize: setting.UI.ExplorePagingNum}, IsActive: optional.Some(true), Visible: []structs.VisibleType{structs.VisibleTypePublic, structs.VisibleTypeLimited, structs.VisibleTypePrivate}, + OrderBy: db.SearchOrderBy(sortOrder), SupportedSortOrders: supportedSortOrders, }, tplExploreUsers) diff --git a/routers/web/org/home.go b/routers/web/org/home.go index a88c846176..7c52455d31 100644 --- a/routers/web/org/home.go +++ b/routers/web/org/home.go @@ -101,7 +101,26 @@ func home(ctx *context.Context, viewRepositories bool) { const orgOverviewTeamsLimit = 5 ctx.Data["OrgOverviewMembers"] = members - ctx.Data["OrgOverviewTeams"] = ctx.Org.Teams[:min(len(ctx.Org.Teams), orgOverviewTeamsLimit)] + // The overview widget shows only teams the viewer belongs to. ctx.Org.Teams + // may include visible-but-not-joined teams (via IncludeVisibilities for + // signed-in non-members), so re-query the viewer's own membership; owners + // keep the full list they are entitled to manage. + overviewTeams := ctx.Org.Teams + if !ctx.Org.IsOwner { + overviewTeams = nil + if ctx.Org.IsMember { + overviewTeams, _, err = organization.SearchTeam(ctx, &organization.SearchTeamOptions{ + OrgID: org.ID, + UserID: ctx.Doer.ID, + ListOptions: db.ListOptions{Page: 1, PageSize: orgOverviewTeamsLimit}, + }) + if err != nil { + ctx.ServerError("SearchTeam", err) + return + } + } + } + ctx.Data["OrgOverviewTeams"] = overviewTeams[:min(len(overviewTeams), orgOverviewTeamsLimit)] ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull ctx.Data["ShowMemberAndTeamTab"] = ctx.Org.IsMember || len(members) > 0 diff --git a/routers/web/org/teams.go b/routers/web/org/teams.go index a026f4aa07..11c4a1da3a 100644 --- a/routers/web/org/teams.go +++ b/routers/web/org/teams.go @@ -21,6 +21,7 @@ import ( user_model "gitea.dev/models/user" "gitea.dev/modules/log" "gitea.dev/modules/setting" + "gitea.dev/modules/structs" "gitea.dev/modules/templates" "gitea.dev/modules/util" "gitea.dev/modules/web" @@ -80,6 +81,8 @@ func Teams(ctx *context.Context) { UserID: util.Iif(shouldSeeAllOrgTeams, 0, ctx.Doer.ID), Keyword: keyword, IncludeDesc: true, + IncludeVisibilities: util.Iif(shouldSeeAllOrgTeams, nil, + org_model.VisibleTeamVisibilitiesFor(ctx.Org.IsMember, ctx.IsSigned)), ListOptions: db.ListOptions{Page: page, PageSize: pagingNum}, } return org_model.SearchTeam(ctx, opts) @@ -377,6 +380,7 @@ func NewTeamPost(ctx *context.Context) { AccessMode: teamPermission, IncludesAllRepositories: includesAllRepositories, CanCreateOrgRepo: form.CanCreateOrgRepo, + Visibility: org_model.NormalizeTeamVisibility(form.Visibility), } units := make([]*org_model.TeamUnit, 0, len(unitPerms)) @@ -477,13 +481,22 @@ func SearchTeam(ctx *context.Context) { PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), } + shouldSeeAll, err := context.UserShouldSeeAllOrgTeams(ctx) + if err != nil { + ctx.ServerError("UserShouldSeeAllOrgTeams", err) + return + } + opts := &org_model.SearchTeamOptions{ - // UserID is not set because the router already requires the doer to be an org admin. Thus, we don't need to restrict to teams that the user belongs in Keyword: ctx.FormTrim("q"), OrgID: ctx.Org.Organization.ID, IncludeDesc: ctx.FormString("include_desc") == "" || ctx.FormBool("include_desc"), ListOptions: listOptions, } + if !shouldSeeAll { + opts.UserID = ctx.Doer.ID + opts.IncludeVisibilities = org_model.VisibleTeamVisibilitiesFor(ctx.Org.IsMember, ctx.IsSigned) + } teams, maxResults, err := org_model.SearchTeam(ctx, opts) if err != nil { @@ -556,8 +569,11 @@ func EditTeamPost(ctx *context.Context) { t.IncludesAllRepositories = includesAllRepositories } t.CanCreateOrgRepo = form.CanCreateOrgRepo + t.Visibility = org_model.NormalizeTeamVisibility(form.Visibility) } else { t.CanCreateOrgRepo = true + // The owner team must remain listable to all org members. + t.Visibility = structs.VisibleTypeLimited } t.Description = form.Description diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go index 45735fc8fe..5a9fa1c1d1 100644 --- a/routers/web/repo/compare.go +++ b/routers/web/repo/compare.go @@ -201,12 +201,12 @@ func newComparePageInfo() *comparePageInfoType { } // parseCompareInfo parse compare info between two commit for preparing comparing references -func (cpi *comparePageInfoType) parseCompareInfo(ctx *context.Context) error { +func (cpi *comparePageInfoType) parseCompareInfo(ctx *context.Context, compareParam string) error { baseRepo := ctx.Repo.Repository fileOnly := ctx.FormBool("file-only") // 1 Parse compare router param - compareReq := common.ParseCompareRouterParam(ctx.PathParam("*")) + compareReq := common.ParseCompareRouterParam(compareParam) // remove the check when we support compare with carets if compareReq.BaseOriRefSuffix != "" { @@ -545,7 +545,7 @@ func getBranchesAndTagsForRepo(ctx gocontext.Context, repo *repo_model.Repositor // CompareDiff show different from one commit to another commit func CompareDiff(ctx *context.Context) { comparePageInfo := newComparePageInfo() - err := comparePageInfo.parseCompareInfo(ctx) + err := comparePageInfo.parseCompareInfo(ctx, ctx.PathParam("*")) if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrInvalidArgument) { ctx.NotFound(nil) return @@ -605,6 +605,45 @@ func CompareDiff(ctx *context.Context) { ctx.HTML(http.StatusOK, tplCompare) } +// DownloadCompareDiff render a comparison's raw unified diff +func DownloadCompareDiff(ctx *context.Context) { + downloadCompareDiffOrPatch(ctx, false) +} + +// DownloadComparePatch render a comparison as a git format-patch +func DownloadComparePatch(ctx *context.Context) { + downloadCompareDiffOrPatch(ctx, true) +} + +func downloadCompareDiffOrPatch(ctx *context.Context, patch bool) { + // The route captures `basehead` separately so the `.diff`/`.patch` suffix is + // stripped from the catch-all `*` param parseCompareInfo would otherwise read. + cpi := newComparePageInfo() + if err := cpi.parseCompareInfo(ctx, ctx.PathParam("basehead")); err != nil { + if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrInvalidArgument) { + ctx.NotFound(nil) + } else { + ctx.ServerError("ParseCompareInfo", err) + } + return + } + ci := cpi.compareInfo + + ctx.Resp.Header().Set("Content-Type", "text/plain; charset=utf-8") + compareArg := ci.BaseCommitID + ci.CompareSeparator + ci.HeadCommitID + + var err error + if patch { + err = ci.HeadGitRepo.GetPatch(compareArg, ctx.Resp) + } else { + err = ci.HeadGitRepo.GetDiff(compareArg, ctx.Resp) + } + if err != nil { + ctx.ServerError("DownloadCompareDiffOrPatch", err) + return + } +} + func (cpi *comparePageInfoType) prepareCreatePullRequestPage(ctx *context.Context) { ci := cpi.compareInfo if cpi.allowCreatePull { diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index 650f94110f..81283a2538 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -1310,7 +1310,7 @@ func CompareAndPullRequestPost(ctx *context.Context) { form := web.GetForm(ctx).(*forms.CreateIssueForm) repo := ctx.Repo.Repository comparePageInfo := newComparePageInfo() - err := comparePageInfo.parseCompareInfo(ctx) + err := comparePageInfo.parseCompareInfo(ctx, ctx.PathParam("*")) if errors.Is(err, util.ErrNotExist) { ctx.JSONErrorNotFound() return diff --git a/routers/web/user/setting/security/openid_test.go b/routers/web/user/setting/security/openid_test.go index 046e7357e1..f00506effa 100644 --- a/routers/web/user/setting/security/openid_test.go +++ b/routers/web/user/setting/security/openid_test.go @@ -15,22 +15,16 @@ import ( func TestDeleteOpenIDReturnsNotFoundForOtherUsersAddress(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := contexttest.MockContext(t, "POST /user/settings/security") + ctx, _ := contexttest.MockContext(t, "POST /user/settings/security?id=1") contexttest.LoadUser(t, ctx, 2) - ctx.SetFormString("id", "1") - DeleteOpenID(ctx) - assert.Equal(t, http.StatusNotFound, ctx.Resp.WrittenStatus()) } func TestToggleOpenIDVisibilityReturnsNotFoundForOtherUsersAddress(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := contexttest.MockContext(t, "POST /user/settings/security") + ctx, _ := contexttest.MockContext(t, "POST /user/settings/security?id=1") contexttest.LoadUser(t, ctx, 2) - ctx.SetFormString("id", "1") - ToggleOpenIDVisibility(ctx) - assert.Equal(t, http.StatusNotFound, ctx.Resp.WrittenStatus()) } diff --git a/routers/web/web.go b/routers/web/web.go index a933ac1c4d..db2cb8b53e 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1275,9 +1275,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Get("/commit/*", context.RepoRefByType(git.RefTypeCommit), repo.TreeViewNodes) }) m.Get("/compare", repo.MustBeNotEmpty, repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.CompareDiff) - m.Combo("/compare/*", repo.MustBeNotEmpty, repo.SetEditorconfigIfExists). - Get(repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.CompareDiff). - Post(reqSignIn, context.RepoMustNotBeArchived(), reqUnitPullsReader, repo.MustAllowPulls, web.Bind(forms.CreateIssueForm{}), repo.SetWhitespaceBehavior, repo.CompareAndPullRequestPost) + m.PathGroup("/compare/*", func(g *web.RouterPathGroup) { + g.MatchPath("GET", "/.diff", repo.MustBeNotEmpty, repo.DownloadCompareDiff) + g.MatchPath("GET", "/.patch", repo.MustBeNotEmpty, repo.DownloadComparePatch) + g.MatchPath("GET", "/<*:*>", repo.MustBeNotEmpty, repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.CompareDiff) + g.MatchPath("POST", "/<*:*>", repo.MustBeNotEmpty, repo.SetEditorconfigIfExists, reqSignIn, context.RepoMustNotBeArchived(), reqUnitPullsReader, repo.MustAllowPulls, web.Bind(forms.CreateIssueForm{}), repo.SetWhitespaceBehavior, repo.CompareAndPullRequestPost) + }) m.Get("/pulls/new/*", repo.PullsNewRedirect) }, optSignIn, context.RepoAssignment, reqUnitCodeReader) // end "/{username}/{reponame}": repo code: find, compare, list diff --git a/services/auth/basic.go b/services/auth/basic.go index c7db14e6e7..ed2a2e1945 100644 --- a/services/auth/basic.go +++ b/services/auth/basic.go @@ -5,6 +5,7 @@ package auth import ( + "errors" "net/http" actions_model "gitea.dev/models/actions" @@ -104,8 +105,8 @@ func (b *Basic) VerifyAuthToken(req *http.Request, w http.ResponseWriter, store store.GetData()["IsApiToken"] = true store.GetData()["ApiTokenScope"] = token.Scope return u, nil - } else if !auth_model.IsErrAccessTokenNotExist(err) && !auth_model.IsErrAccessTokenEmpty(err) { - log.Error("GetAccessTokenBySha: %v", err) + } else if !errors.Is(err, util.ErrNotExist) { + log.Error("GetAccessTokenBySHA: %v", err) } // check task token diff --git a/services/auth/oauth2.go b/services/auth/oauth2.go index a2f7d5d1e7..cb622c2258 100644 --- a/services/auth/oauth2.go +++ b/services/auth/oauth2.go @@ -128,7 +128,7 @@ func (o *OAuth2) userFromToken(ctx context.Context, tokenSHA string, store DataS } t, err := auth_model.GetAccessTokenBySHA(ctx, tokenSHA) if err != nil { - if auth_model.IsErrAccessTokenNotExist(err) { + if errors.Is(err, util.ErrNotExist) { // check task token if task, err := actions_model.GetRunningTaskByToken(ctx, tokenSHA); err == nil { log.Trace("Basic Authorization: Valid AccessToken for task[%d]", task.ID) diff --git a/services/context/base_form.go b/services/context/base_form.go index 088888b461..c6a7099129 100644 --- a/services/context/base_form.go +++ b/services/context/base_form.go @@ -78,8 +78,3 @@ func (b *Base) FormOptionalBool(key string) optional.Option[bool] { v = v || strings.EqualFold(s, "on") return optional.Some(v) } - -func (b *Base) SetFormString(key, value string) { - _ = b.Req.FormValue(key) // force parse form - b.Req.Form.Set(key, value) -} diff --git a/services/context/org.go b/services/context/org.go index 79dcfab5f2..0f6a06e326 100644 --- a/services/context/org.go +++ b/services/context/org.go @@ -179,20 +179,28 @@ func OrgAssignment(orgAssignmentOpts OrgAssignmentOptions) func(ctx *Context) { ctx.ServerError("UserShouldSeeAllOrgTeams", err) return } - if ctx.Org.IsMember { - if shouldSeeAllTeams { - ctx.Org.Teams, err = org.LoadTeams(ctx) - if err != nil { - ctx.ServerError("LoadTeams", err) - return - } - } else { - ctx.Org.Teams, err = org.GetUserTeams(ctx, ctx.Doer.ID) - if err != nil { - ctx.ServerError("GetUserTeams", err) - return - } + switch { + case shouldSeeAllTeams: + ctx.Org.Teams, err = org.LoadTeams(ctx) + if err != nil { + ctx.ServerError("LoadTeams", err) + return } + case ctx.IsSigned: + // Signed-in non-members still see teams whose visibility tier + // includes them (public for any signed-in user, plus limited + // for org members), and any team they directly belong to. + ctx.Org.Teams, _, err = organization.SearchTeam(ctx, &organization.SearchTeamOptions{ + OrgID: org.ID, + UserID: ctx.Doer.ID, + IncludeVisibilities: organization.VisibleTeamVisibilitiesFor(ctx.Org.IsMember, true), + }) + if err != nil { + ctx.ServerError("SearchTeam", err) + return + } + } + if ctx.Org.IsMember { ctx.Data["NumTeams"] = len(ctx.Org.Teams) } @@ -203,7 +211,6 @@ func OrgAssignment(orgAssignmentOpts OrgAssignmentOptions) func(ctx *Context) { if strings.EqualFold(team.LowerName, teamName) { teamExists = true ctx.Org.Team = team - ctx.Org.IsTeamMember = true ctx.Data["Team"] = ctx.Org.Team break } @@ -214,13 +221,24 @@ func OrgAssignment(orgAssignmentOpts OrgAssignmentOptions) func(ctx *Context) { return } + // Membership in a visible team is not implied by its presence in + // ctx.Org.Teams; admins/org owners keep the privileged flag set + // earlier in this function. + if !ctx.Org.IsOwner { + ctx.Org.IsTeamMember, err = organization.IsTeamMember(ctx, org.ID, ctx.Org.Team.ID, ctx.Doer.ID) + if err != nil { + ctx.ServerError("IsTeamMember", err) + return + } + } ctx.Data["IsTeamMember"] = ctx.Org.IsTeamMember if opts.RequireTeamMember && !ctx.Org.IsTeamMember { ctx.NotFound(err) return } - ctx.Org.IsTeamAdmin = ctx.Org.Team.IsOwnerTeam() || ctx.Org.Team.HasAdminAccess() + isTeamOwnerOrAdmin := ctx.Org.Team.IsOwnerTeam() || ctx.Org.Team.HasAdminAccess() + ctx.Org.IsTeamAdmin = ctx.Org.IsOwner || (ctx.Org.IsTeamMember && isTeamOwnerOrAdmin) ctx.Data["IsTeamAdmin"] = ctx.Org.IsTeamAdmin if opts.RequireTeamAdmin && !ctx.Org.IsTeamAdmin { ctx.NotFound(err) diff --git a/services/convert/convert.go b/services/convert/convert.go index 49d74f7d10..d1437de286 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -836,6 +836,7 @@ func ToTeams(ctx context.Context, teams []*organization.Team, loadOrgs bool) ([] Permission: api.AccessLevelName(t.AccessMode.ToString()), Units: t.GetUnitNames(), UnitsMap: t.GetUnitsMap(), + Visibility: api.TeamVisibility(t.Visibility.String()), } if loadOrgs { diff --git a/services/forms/org.go b/services/forms/org.go index 2aacf1b645..be3f64d630 100644 --- a/services/forms/org.go +++ b/services/forms/org.go @@ -70,6 +70,7 @@ type CreateTeamForm struct { Permission string RepoAccess string CanCreateOrgRepo bool + Visibility string `binding:"OmitEmpty;In(public,limited,private)"` } // Validate validates the fields diff --git a/services/org/team.go b/services/org/team.go index f3313e0edf..b079313306 100644 --- a/services/org/team.go +++ b/services/org/team.go @@ -110,7 +110,7 @@ func UpdateTeam(ctx context.Context, t *organization.Team, authChanged, includeA sess := db.GetEngine(ctx) if _, err = sess.ID(t.ID).Cols("name", "lower_name", "description", - "can_create_org_repo", "authorize", "includes_all_repositories").Update(t); err != nil { + "can_create_org_repo", "authorize", "includes_all_repositories", "visibility").Update(t); err != nil { return fmt.Errorf("update: %w", err) } diff --git a/templates/org/team/new.tmpl b/templates/org/team/new.tmpl index f8785bb466..7e0403a2f5 100644 --- a/templates/org/team/new.tmpl +++ b/templates/org/team/new.tmpl @@ -12,10 +12,10 @@ {{template "base/alert" .}}
- {{if eq .Team.LowerName "owners"}} + {{if .Team.IsOwnerTeam}} {{end}} - + {{ctx.Locale.Tr "org.team_name_helper"}}
@@ -23,7 +23,47 @@ {{ctx.Locale.Tr "org.team_desc_helper"}}
- {{if not (eq .Team.LowerName "owners")}} + {{if .Team.IsOwnerTeam}} +
+ +
+ {{if .Team.IsPrivate}} + {{ctx.Locale.Tr "org.teams.visibility_private"}} + {{else if .Team.IsLimited}} + {{ctx.Locale.Tr "org.teams.visibility_limited"}} + {{else if .Team.IsPublic}} + {{ctx.Locale.Tr "org.teams.visibility_public"}} + {{end}} +
+ {{ctx.Locale.Tr "org.teams.owners_visibility_fixed"}} +
+ {{end}} + {{if not .Team.IsOwnerTeam}} +
+ +
+
+
+ + + {{ctx.Locale.Tr "org.teams.visibility_private_helper"}} +
+
+
+
+ + + {{ctx.Locale.Tr "org.teams.visibility_limited_helper"}} +
+
+
+
+ + + {{ctx.Locale.Tr "org.teams.visibility_public_helper"}} +
+
+

@@ -135,7 +175,7 @@ {{else}} - {{if not (eq .Team.LowerName "owners")}} + {{if not .Team.IsOwnerTeam}} {{end}} {{end}} diff --git a/templates/org/team/sidebar.tmpl b/templates/org/team/sidebar.tmpl index 1036e886da..a0b936285e 100644 --- a/templates/org/team/sidebar.tmpl +++ b/templates/org/team/sidebar.tmpl @@ -1,6 +1,15 @@

- {{.Team.Name}} +
+ {{.Team.Name}} + {{if .Team.IsPrivate}} + {{ctx.Locale.Tr "org.teams.visibility_private"}} + {{else if .Team.IsLimited}} + {{ctx.Locale.Tr "org.teams.visibility_limited"}} + {{else if .Team.IsPublic}} + {{ctx.Locale.Tr "org.teams.visibility_public"}} + {{end}} +
{{if .Team.IsMember ctx $.SignedUser.ID}}

-
+
{{if .IsFileTooLarge}} {{template "shared/filetoolarge" dict "RawFileLink" .RawFileLink}} diff --git a/templates/repo/settings/lfs_file.tmpl b/templates/repo/settings/lfs_file.tmpl index b04dc16cdf..96148f57a9 100644 --- a/templates/repo/settings/lfs_file.tmpl +++ b/templates/repo/settings/lfs_file.tmpl @@ -11,7 +11,7 @@ {{ctx.Locale.Tr "repo.settings.lfs_findcommits"}}
-
+
{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus "root" $}}
{{if .IsFileTooLarge}} diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index 9f936afb8e..7d139efceb 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -91,7 +91,7 @@
-
+
{{if not .RenderAsMarkup}} {{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus}} {{end}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index f089724810..459e7d9e10 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -8191,8 +8191,10 @@ }, "/repos/{owner}/{repo}/compare/{basehead}": { "get": { + "description": "By default returns JSON commit comparison information. The raw diff or patch can be\nrequested with the `output` query parameter set to `diff` or `patch` respectively.\n", "produces": [ - "application/json" + "application/json", + "text/plain" ], "tags": [ "repository" @@ -8216,10 +8218,20 @@ }, { "type": "string", - "description": "compare two branches or commits", + "description": "compare two refs as `base...head` (or `base..head`); refs may be branches, tags, full or short SHAs, including branch names that contain slashes.", "name": "basehead", "in": "path", "required": true + }, + { + "enum": [ + "diff", + "patch" + ], + "type": "string", + "description": "return the raw comparison as `diff` or `patch` instead of JSON", + "name": "output", + "in": "query" } ], "responses": { @@ -19285,6 +19297,38 @@ } } }, + "/token": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "miscellaneous" + ], + "summary": "Get the currently authenticated token", + "operationId": "getCurrentToken", + "responses": { + "200": { + "$ref": "#/responses/CurrentAccessToken" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "miscellaneous" + ], + "summary": "Delete the currently authenticated token", + "operationId": "deleteCurrentToken", + "responses": { + "204": { + "description": "token deleted" + } + } + } + }, "/topics/search": { "get": { "produces": [ @@ -25102,6 +25146,17 @@ }, "x-go-name": "UnitsMap", "example": "{\"repo.actions\",\"repo.packages\",\"repo.code\":\"read\",\"repo.issues\":\"write\",\"repo.ext_issues\":\"none\",\"repo.wiki\":\"admin\",\"repo.pulls\":\"owner\",\"repo.releases\":\"none\",\"repo.projects\":\"none\",\"repo.ext_wiki\":\"none\"}" + }, + "visibility": { + "description": "Team visibility within the organization. Defaults to \"private\".\npublic TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate", + "type": "string", + "enum": [ + "public", + "limited", + "private" + ], + "x-go-enum-desc": "public TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate", + "x-go-name": "Visibility" } }, "x-go-package": "gitea.dev/modules/structs" @@ -25258,6 +25313,47 @@ }, "x-go-package": "gitea.dev/modules/structs" }, + "CurrentAccessToken": { + "type": "object", + "title": "CurrentAccessToken represents the metadata of the currently authenticated token.", + "properties": { + "created_at": { + "description": "The timestamp when the token was created", + "type": "string", + "format": "date-time", + "x-go-name": "CreatedAt" + }, + "id": { + "description": "The unique identifier of the access token", + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "last_used_at": { + "description": "The timestamp when the token was last used", + "type": "string", + "format": "date-time", + "x-go-name": "LastUsedAt" + }, + "name": { + "description": "The name of the access token", + "type": "string", + "x-go-name": "Name" + }, + "scopes": { + "description": "The scopes granted to this access token", + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "Scopes" + }, + "user": { + "$ref": "#/definitions/UserMeta" + } + }, + "x-go-package": "gitea.dev/modules/structs" + }, "DeleteEmailOption": { "description": "DeleteEmailOption options when deleting email addresses", "type": "object", @@ -26259,6 +26355,17 @@ "repo.releases": "none", "repo.wiki": "admin" } + }, + "visibility": { + "description": "Team visibility within the organization. When omitted, visibility is\nleft unchanged.\npublic TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate", + "type": "string", + "enum": [ + "public", + "limited", + "private" + ], + "x-go-enum-desc": "public TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate", + "x-go-name": "Visibility" } }, "x-go-package": "gitea.dev/modules/structs" @@ -30171,6 +30278,17 @@ "repo.releases": "none", "repo.wiki": "admin" } + }, + "visibility": { + "description": "Team visibility within the organization. \"private\" teams are only\nlistable by members and org owners; \"limited\" teams are listable by\nany organization member; \"public\" teams are listable by any signed-in\nuser.\npublic TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate", + "type": "string", + "enum": [ + "public", + "limited", + "private" + ], + "x-go-enum-desc": "public TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate", + "x-go-name": "Visibility" } }, "x-go-package": "gitea.dev/modules/structs" @@ -30733,6 +30851,24 @@ }, "x-go-package": "gitea.dev/models/activities" }, + "UserMeta": { + "type": "object", + "title": "UserMeta represents minimal user information for the token owner.", + "properties": { + "id": { + "description": "The unique identifier of the user", + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "login": { + "description": "The username of the user", + "type": "string", + "x-go-name": "Login" + } + }, + "x-go-package": "gitea.dev/modules/structs" + }, "UserSettings": { "description": "UserSettings represents user settings", "type": "object", @@ -31237,6 +31373,12 @@ } } }, + "CurrentAccessToken": { + "description": "CurrentAccessToken represents the currently authenticated access token.", + "schema": { + "$ref": "#/definitions/CurrentAccessToken" + } + }, "DeployKey": { "description": "DeployKey", "schema": { diff --git a/templates/swagger/v1_openapi3_json.tmpl b/templates/swagger/v1_openapi3_json.tmpl index c7287ac724..06c1216517 100644 --- a/templates/swagger/v1_openapi3_json.tmpl +++ b/templates/swagger/v1_openapi3_json.tmpl @@ -399,6 +399,16 @@ }, "description": "CronList" }, + "CurrentAccessToken": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CurrentAccessToken" + } + } + }, + "description": "CurrentAccessToken represents the currently authenticated access token." + }, "DeployKey": { "content": { "application/json": { @@ -4795,6 +4805,14 @@ "example": "{\"repo.actions\",\"repo.packages\",\"repo.code\":\"read\",\"repo.issues\":\"write\",\"repo.ext_issues\":\"none\",\"repo.wiki\":\"admin\",\"repo.pulls\":\"owner\",\"repo.releases\":\"none\",\"repo.projects\":\"none\",\"repo.ext_wiki\":\"none\"}", "type": "object", "x-go-name": "UnitsMap" + }, + "visibility": { + "allOf": [ + { + "$ref": "#/components/schemas/TeamVisibility" + } + ], + "description": "Team visibility within the organization. Defaults to \"private\".\npublic TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate" } }, "required": [ @@ -4952,6 +4970,47 @@ "type": "object", "x-go-package": "gitea.dev/modules/structs" }, + "CurrentAccessToken": { + "properties": { + "created_at": { + "description": "The timestamp when the token was created", + "format": "date-time", + "type": "string", + "x-go-name": "CreatedAt" + }, + "id": { + "description": "The unique identifier of the access token", + "format": "int64", + "type": "integer", + "x-go-name": "ID" + }, + "last_used_at": { + "description": "The timestamp when the token was last used", + "format": "date-time", + "type": "string", + "x-go-name": "LastUsedAt" + }, + "name": { + "description": "The name of the access token", + "type": "string", + "x-go-name": "Name" + }, + "scopes": { + "description": "The scopes granted to this access token", + "items": { + "type": "string" + }, + "type": "array", + "x-go-name": "Scopes" + }, + "user": { + "$ref": "#/components/schemas/UserMeta" + } + }, + "title": "CurrentAccessToken represents the metadata of the currently authenticated token.", + "type": "object", + "x-go-package": "gitea.dev/modules/structs" + }, "DeleteEmailOption": { "description": "DeleteEmailOption options when deleting email addresses", "properties": { @@ -5940,6 +5999,14 @@ }, "type": "object", "x-go-name": "UnitsMap" + }, + "visibility": { + "allOf": [ + { + "$ref": "#/components/schemas/TeamVisibility" + } + ], + "description": "Team visibility within the organization. When omitted, visibility is\nleft unchanged.\npublic TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate" } }, "required": [ @@ -9893,11 +9960,27 @@ }, "type": "object", "x-go-name": "UnitsMap" + }, + "visibility": { + "allOf": [ + { + "$ref": "#/components/schemas/TeamVisibility" + } + ], + "description": "Team visibility within the organization. \"private\" teams are only\nlistable by members and org owners; \"limited\" teams are listable by\nany organization member; \"public\" teams are listable by any signed-in\nuser.\npublic TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate" } }, "type": "object", "x-go-package": "gitea.dev/modules/structs" }, + "TeamVisibility": { + "enum": [ + "public", + "limited", + "private" + ], + "type": "string" + }, "TimeStamp": { "description": "TimeStamp defines a timestamp", "format": "int64", @@ -10460,6 +10543,24 @@ "type": "object", "x-go-package": "gitea.dev/models/activities" }, + "UserMeta": { + "properties": { + "id": { + "description": "The unique identifier of the user", + "format": "int64", + "type": "integer", + "x-go-name": "ID" + }, + "login": { + "description": "The username of the user", + "type": "string", + "x-go-name": "Login" + } + }, + "title": "UserMeta represents minimal user information for the token owner.", + "type": "object", + "x-go-package": "gitea.dev/modules/structs" + }, "UserSettings": { "description": "UserSettings represents user settings", "properties": { @@ -19462,6 +19563,7 @@ }, "/repos/{owner}/{repo}/compare/{basehead}": { "get": { + "description": "By default returns JSON commit comparison information. The raw diff or patch can be\nrequested with the `output` query parameter set to `diff` or `patch` respectively.\n", "operationId": "repoCompareDiff", "parameters": [ { @@ -19483,13 +19585,25 @@ } }, { - "description": "compare two branches or commits", + "description": "compare two refs as `base...head` (or `base..head`); refs may be branches, tags, full or short SHAs, including branch names that contain slashes.", "in": "path", "name": "basehead", "required": true, "schema": { "type": "string" } + }, + { + "description": "return the raw comparison as `diff` or `patch` instead of JSON", + "in": "query", + "name": "output", + "schema": { + "enum": [ + "diff", + "patch" + ], + "type": "string" + } } ], "responses": { @@ -31480,6 +31594,32 @@ ] } }, + "/token": { + "delete": { + "operationId": "deleteCurrentToken", + "responses": { + "204": { + "description": "token deleted" + } + }, + "summary": "Delete the currently authenticated token", + "tags": [ + "miscellaneous" + ] + }, + "get": { + "operationId": "getCurrentToken", + "responses": { + "200": { + "$ref": "#/components/responses/CurrentAccessToken" + } + }, + "summary": "Get the currently authenticated token", + "tags": [ + "miscellaneous" + ] + } + }, "/topics/search": { "get": { "operationId": "topicSearch", diff --git a/tests/integration/api_issue_tracked_time_test.go b/tests/integration/api_issue_tracked_time_test.go index fcbe4dfa51..0ae8a858ac 100644 --- a/tests/integration/api_issue_tracked_time_test.go +++ b/tests/integration/api_issue_tracked_time_test.go @@ -13,6 +13,7 @@ import ( issues_model "gitea.dev/models/issues" "gitea.dev/models/unittest" user_model "gitea.dev/models/user" + "gitea.dev/modules/json" api "gitea.dev/modules/structs" "gitea.dev/tests" @@ -61,6 +62,44 @@ func TestAPIGetTrackedTimes(t *testing.T) { assert.Equal(t, int64(6), filterAPITimes[1].ID) } +// TestAPIGetTrackedTimesNonExistentUserFilter ensures filtering by a user that +// does not exist returns a clean 404 instead of panicking (nil pointer dereference). +func TestAPIGetTrackedTimesNonExistentUserFilter(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + issue2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2}) + assert.NoError(t, issue2.LoadRepo(t.Context())) + + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue, auth_model.AccessTokenScopeReadRepository) + + for _, tc := range []struct { + name string + url string + }{ + {"repository level", fmt.Sprintf("/api/v1/repos/%s/%s/times?user=nonexistentuser", user2.Name, issue2.Repo.Name)}, + {"issue level", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/times?user=nonexistentuser", user2.Name, issue2.Repo.Name, issue2.Index)}, + } { + t.Run(tc.name, func(t *testing.T) { + req := NewRequest(t, "GET", tc.url).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusNotFound) + + assert.True(t, json.Valid(resp.Body.Bytes()), "response body must be a single JSON value, got: %s", resp.Body.Bytes()) + + var apiError api.APIError + DecodeJSON(t, resp, &apiError) + assert.Contains(t, apiError.Message, "user does not exist") + }) + } + + t.Run("existing user", func(t *testing.T) { + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/times?user=%s", user2.Name, issue2.Repo.Name, user2.Name).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, api.TrackedTimeList{}) + }) +} + func TestAPIDeleteTrackedTime(t *testing.T) { defer tests.PrepareTestEnv(t)() diff --git a/tests/integration/api_repo_compare_test.go b/tests/integration/api_repo_compare_test.go index 0d8cec7776..5bb8cf6bde 100644 --- a/tests/integration/api_repo_compare_test.go +++ b/tests/integration/api_repo_compare_test.go @@ -6,6 +6,7 @@ package integration import ( "net/http" "net/url" + "strings" "testing" auth_model "gitea.dev/models/auth" @@ -62,3 +63,113 @@ func TestAPICompareBranches(t *testing.T) { }) }) } + +func TestAPIDownloadCompareDiffOrPatch(t *testing.T) { + onGiteaRun(t, func(t *testing.T, _ *url.URL) { + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + + t.Run("BranchToBranchDiff", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo20/compare/add-csv...remove-files-b?output=diff").AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type")) + body := resp.Body.String() + assert.Contains(t, body, "diff --git ") + }) + + t.Run("BranchToBranchPatch", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo20/compare/add-csv...remove-files-b?output=patch").AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type")) + body := resp.Body.String() + assert.True(t, strings.HasPrefix(body, "From "), "patch output should start with a format-patch header, got: %q", body[:min(40, len(body))]) + }) + + t.Run("CommitToCommitDiff", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo20/compare/808038d2f71b0ab02099...c8e31bc7688741a5287f?output=diff").AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + assert.Contains(t, resp.Body.String(), "diff --git ") + }) + + t.Run("BranchToCommitDiff", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // 8babce96... is the head of remove-files-b; pairing it with add-csv guarantees a non-empty diff. + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo20/compare/add-csv...8babce967f21b9dfa6987f943b91093dac58a4f0?output=diff").AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + assert.Contains(t, resp.Body.String(), "diff --git ") + }) + + t.Run("TwoDotSeparator", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo20/compare/add-csv..remove-files-b?output=diff").AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + assert.Contains(t, resp.Body.String(), "diff --git ") + }) + + t.Run("SlashedBranchName", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // user2/repo1's `feature/1` branch contains a slash; the route must match it + // without URL-encoding. master and feature/1 happen to share a SHA in the fixture, + // so we only assert the route resolves (200 OK) rather than checking diff content. + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/compare/master...feature/1?output=diff").AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type")) + }) + + t.Run("UnknownOutputReturnsJSON", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Only "diff"/"patch" switch to raw output; any other value falls through to JSON. + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo20/compare/add-csv...remove-files-b?output=foo").AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + apiResp := DecodeJSON(t, resp, &api.Compare{}) + assert.Equal(t, 2, apiResp.TotalCommits) + }) + + t.Run("SingleRefImplicitBase", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // No `...`/`..` separator: parseCompareInfo defaults the base to the + // repo's PR target branch (master for repo20) and compares it against + // the given head. + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo20/compare/add-csv?output=diff").AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type")) + assert.Contains(t, resp.Body.String(), "diff --git ") + }) + + t.Run("PrivateRepoAnonymous", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // repo16 is private; an unauthenticated request must not leak its existence. + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo16/compare/master...good-sign?output=diff") + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("CrossRepoFork", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + user13 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 13}) + repo11 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 11}) + user13Sess := loginUser(t, "user13") + user13Token := getTokenForLoggedInUser(t, user13Sess, auth_model.AccessTokenScopeWriteRepository) + + _, err := createFileInBranch(user13, repo11, createFileInBranchOptions{OldBranch: "master", NewBranch: "cross-repo-diff"}, map[string]string{"hello.txt": "hi\n"}) + require.NoError(t, err) + + req := NewRequest(t, "GET", "/api/v1/repos/user12/repo10/compare/master...user13:cross-repo-diff?output=diff").AddTokenAuth(user13Token) + resp := MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type")) + assert.Contains(t, resp.Body.String(), "diff --git ") + }) + }) +} diff --git a/tests/integration/api_team_test.go b/tests/integration/api_team_test.go index 7f35d59669..08dd53e82f 100644 --- a/tests/integration/api_team_test.go +++ b/tests/integration/api_team_test.go @@ -10,12 +10,14 @@ import ( "testing" auth_model "gitea.dev/models/auth" + "gitea.dev/models/db" "gitea.dev/models/organization" "gitea.dev/models/perm" "gitea.dev/models/repo" "gitea.dev/models/unit" "gitea.dev/models/unittest" user_model "gitea.dev/models/user" + "gitea.dev/modules/structs" api "gitea.dev/modules/structs" "gitea.dev/services/convert" "gitea.dev/tests" @@ -303,3 +305,61 @@ func TestAPIGetTeamRepo(t *testing.T) { AddTokenAuth(token5) MakeRequest(t, req, http.StatusNotFound) } + +func TestAPITeamVisibilityAccess(t *testing.T) { + defer tests.PrepareTestEnv(t)() + insertTestTeam := func(t *testing.T, orgID int64, name string, visibility structs.VisibleType) *organization.Team { + t.Helper() + team := &organization.Team{ + OrgID: orgID, + LowerName: name, + Name: name, + AccessMode: perm.AccessModeRead, + Visibility: visibility, + } + assert.NoError(t, db.Insert(t.Context(), team)) + return team + } + + limitedTeam := insertTestTeam(t, 3, "limited-team", structs.VisibleTypeLimited) + + // Org member who can read a limited team must not mutate its repos without membership. + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + token := getUserToken(t, user4.Name, auth_model.AccessTokenScopeWriteOrganization) + req := NewRequestf(t, "PUT", "/api/v1/teams/%d/repos/org3/repo3", limitedTeam.ID). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + + publicTeam := insertTestTeam(t, 23, "public-team", structs.VisibleTypePublic) + + // Public team in a private org must not be readable by outsiders. + outsider := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + token = getUserToken(t, outsider.Name, auth_model.AccessTokenScopeReadOrganization) + req = NewRequestf(t, "GET", "/api/v1/teams/%d", publicTeam.ID). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + + // Member lookup must require org membership even for public teams. + req = NewRequestf(t, "GET", "/api/v1/teams/%d/members/%s", publicTeam.ID, outsider.Name). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + + // A limited team's repo list must not leak repos the viewer cannot access. + // repo3 is private; user28 is an org3 member (team12, no repo access) who can + // read the limited team but has no access to repo3. + assert.NoError(t, db.Insert(t.Context(), &organization.TeamRepo{OrgID: 3, TeamID: limitedTeam.ID, RepoID: 3})) + user28 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 28}) + token28 := getUserToken(t, user28.Name, auth_model.AccessTokenScopeReadOrganization) + + req = NewRequestf(t, "GET", "/api/v1/teams/%d/repos", limitedTeam.ID).AddTokenAuth(token28) + resp := MakeRequest(t, req, http.StatusOK) + var repos []*api.Repository + DecodeJSON(t, resp, &repos) + for _, r := range repos { + assert.NotEqual(t, int64(3), r.ID, "must not leak inaccessible private repo3") + } + + // The single-repo lookup must not confirm an inaccessible repo's existence. + req = NewRequestf(t, "GET", "/api/v1/teams/%d/repos/org3/repo3", limitedTeam.ID).AddTokenAuth(token28) + MakeRequest(t, req, http.StatusNotFound) +} diff --git a/tests/integration/api_token_self_test.go b/tests/integration/api_token_self_test.go new file mode 100644 index 0000000000..2720c59d2c --- /dev/null +++ b/tests/integration/api_token_self_test.go @@ -0,0 +1,98 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + auth_model "gitea.dev/models/auth" + "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" + api "gitea.dev/modules/structs" + "gitea.dev/tests" + + "github.com/stretchr/testify/assert" +) + +// TestAPIGetCurrentToken tests getting metadata of the currently authenticated token +func TestAPIGetCurrentToken(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + t.Run("Success with all scopes", func(t *testing.T) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + accessToken := createAPIAccessTokenWithoutCleanUp(t, "test-get-current-token-all", user, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll}) + + req := NewRequest(t, "GET", "/api/v1/token"). + AddTokenAuth(accessToken.Token) + resp := MakeRequest(t, req, http.StatusOK) + + currentToken := DecodeJSON(t, resp, &api.CurrentAccessToken{}) + assert.Equal(t, accessToken.ID, currentToken.ID) + assert.Equal(t, accessToken.Name, currentToken.Name) + assert.Equal(t, user.ID, currentToken.User.ID) + assert.Equal(t, user.Name, currentToken.User.Login) + }) + + t.Run("Success with limited scopes", func(t *testing.T) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + accessToken := createAPIAccessTokenWithoutCleanUp(t, "test-get-current-token-limited", user, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeReadRepository}) + + req := NewRequest(t, "GET", "/api/v1/token"). + AddTokenAuth(accessToken.Token) + resp := MakeRequest(t, req, http.StatusOK) + + currentToken := DecodeJSON(t, resp, &api.CurrentAccessToken{}) + assert.Equal(t, accessToken.ID, currentToken.ID) + assert.Equal(t, accessToken.Name, currentToken.Name) + assert.Equal(t, user.ID, currentToken.User.ID) + assert.Equal(t, user.Name, currentToken.User.Login) + }) + + t.Run("Bad token", func(t *testing.T) { + req := NewRequest(t, "GET", "/api/v1/token"). + AddTokenAuth("this does not exist") + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "GET", "/api/v1/token") + MakeRequest(t, req, http.StatusUnauthorized) + }) +} + +// TestAPITokenSelfService tests delete operations on token +func TestAPITokenSelfService(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + t.Run("Success then verify deleted", func(t *testing.T) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + accessToken := createAPIAccessTokenWithoutCleanUp(t, "test-delete-current-token", user, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll}) + + // Delete the token via the endpoint + req := NewRequest(t, "DELETE", "/api/v1/token"). + AddTokenAuth(accessToken.Token) + MakeRequest(t, req, http.StatusNoContent) + + // Verify the token is deleted + unittest.AssertNotExistsBean(t, &auth_model.AccessToken{ID: accessToken.ID}) + + // Verify the token can no longer be used for GET + req = NewRequest(t, "GET", "/api/v1/token"). + AddTokenAuth(accessToken.Token) + MakeRequest(t, req, http.StatusUnauthorized) + + // Verify the token can no longer be used for DELETE + req = NewRequest(t, "DELETE", "/api/v1/token"). + AddTokenAuth(accessToken.Token) + MakeRequest(t, req, http.StatusUnauthorized) + }) + + t.Run("Bad token", func(t *testing.T) { + req := NewRequest(t, "DELETE", "/api/v1/token"). + AddTokenAuth("this does not exist") + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "DELETE", "/api/v1/token") + MakeRequest(t, req, http.StatusUnauthorized) + }) +} diff --git a/tests/integration/compare_test.go b/tests/integration/compare_test.go index ac2e014d92..d5f5bc2cc2 100644 --- a/tests/integration/compare_test.go +++ b/tests/integration/compare_test.go @@ -167,6 +167,53 @@ Hello from 2 assert.Equal(t, 0, htmlDoc.doc.Find(".pullrequest-form").Length()) } +func TestCompareDownloadDiffOrPatch(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + + t.Run("BranchToBranchDiff", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo20/compare/add-csv...remove-files-b.diff") + resp := session.MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type")) + assert.Contains(t, resp.Body.String(), "diff --git ") + }) + + t.Run("BranchToBranchPatch", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo20/compare/add-csv...remove-files-b.patch") + resp := session.MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type")) + assert.True(t, strings.HasPrefix(resp.Body.String(), "From "), "patch output should start with a format-patch header") + }) + + t.Run("SingleRefImplicitBase", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo20/compare/add-csv.diff") + resp := session.MakeRequest(t, req, http.StatusOK) + assert.Contains(t, resp.Body.String(), "diff --git ") + }) + + t.Run("InvalidBaseRef", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo20/compare/does-not-exist...remove-files-b.diff") + session.MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("PrivateRepoAnonymous", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // repo16 is private; an unauthenticated request must not leak its existence. + req := NewRequest(t, "GET", "/user2/repo16/compare/master...good-sign.diff") + MakeRequest(t, req, http.StatusNotFound) + }) +} + func TestCompareCodeExpand(t *testing.T) { onGiteaRun(t, func(t *testing.T, u *url.URL) { user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) diff --git a/tests/integration/html_helper.go b/tests/integration/html_helper.go index cefe5592c4..fc900e5a3d 100644 --- a/tests/integration/html_helper.go +++ b/tests/integration/html_helper.go @@ -5,13 +5,12 @@ package integration import ( "io" - "slices" - "strings" "testing" + "gitea.dev/modules/test" + "github.com/PuerkitoBio/goquery" "github.com/stretchr/testify/assert" - "golang.org/x/net/html" ) // HTMLDoc struct @@ -53,36 +52,10 @@ func AssertHTMLElement[T int | bool](t testing.TB, doc *HTMLDoc, selector string func assertHTMLEq(t testing.TB, expected, actual string) { t.Helper() - if expected == actual { + if expected == actual { // fast path return } - exp, err := html.Parse(strings.NewReader(expected)) - if !assert.NoError(t, err) { - return - } - act, err := html.Parse(strings.NewReader(actual)) - if !assert.NoError(t, err) { - return - } - var normalize func(n *html.Node) - normalize = func(n *html.Node) { - slices.SortFunc(n.Attr, func(a, b html.Attribute) int { - if cmp := strings.Compare(a.Namespace, b.Namespace); cmp != 0 { - return cmp - } - if cmp := strings.Compare(a.Key, b.Key); cmp != 0 { - return cmp - } - return strings.Compare(a.Val, b.Val) - }) - for c := n.FirstChild; c != nil; c = c.NextSibling { - normalize(c) - } - } - normalize(exp) - normalize(act) - var expNormalized, actNormalized strings.Builder - assert.NoError(t, html.Render(&expNormalized, exp)) - assert.NoError(t, html.Render(&actNormalized, act)) - assert.Equal(t, expNormalized.String(), actNormalized.String()) + exp := test.NormalizeHTMLAttributes(t, expected) + act := test.NormalizeHTMLAttributes(t, actual) + assert.Equal(t, exp, act) } diff --git a/uv.lock b/uv.lock index a14f9745a4..67df36399f 100644 --- a/uv.lock +++ b/uv.lock @@ -39,7 +39,7 @@ wheels = [ [[package]] name = "djlint" -version = "1.36.4" +version = "1.39.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -54,25 +54,63 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/89/ecf5be9f5c59a0c53bcaa29671742c5e269cc7d0e2622e3f65f41df251bf/djlint-1.36.4.tar.gz", hash = "sha256:17254f218b46fe5a714b224c85074c099bcb74e3b2e1f15c2ddc2cf415a408a1", size = 47849, upload-time = "2024-12-24T13:06:36.36Z" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/a7/5ba1032d01ceba641b92b1c76c758a0a06959585c6d36608371526809a08/djlint-1.39.0.tar.gz", hash = "sha256:75e7e1a0c592121751c48360104b3c402f4d6406ea862ba76f8867b3eb51ba97", size = 55174, upload-time = "2026-06-05T19:22:37.296Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/71/6a3ce2b49a62e635b85dce30ccf3eb3a18fe79275d45535325a55a63d3a3/djlint-1.36.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a2dfb60883ceb92465201bfd392291a7597c6752baede6fbb6f1980cac8d6c5c", size = 354135, upload-time = "2024-12-24T13:05:49.732Z" }, - { url = "https://files.pythonhosted.org/packages/72/47/308412dc579e277c910774f41b380308d582862b16763425583e69e0fc14/djlint-1.36.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4bc6a1320c0030244b530ac200642f883d3daa451a115920ef3d56d08b644292", size = 328501, upload-time = "2024-12-24T13:05:53.861Z" }, - { url = "https://files.pythonhosted.org/packages/9b/6f/428dc044d1e34363265b1301dc9b53253007acd858879d54b369d233aa96/djlint-1.36.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3164a048c7bb0baf042387b1e33f9bbbf99d90d1337bb4c3d66eb0f96f5400a1", size = 415849, upload-time = "2024-12-24T13:05:56.377Z" }, - { url = "https://files.pythonhosted.org/packages/d6/13/0d488e551d73ddf369552fc6f4c7702ea683e4bc1305bcf5c1d198fbdace/djlint-1.36.4-cp310-cp310-win_amd64.whl", hash = "sha256:3196d5277da5934962d67ad6c33a948ba77a7b6eadf064648bef6ee5f216b03c", size = 360969, upload-time = "2024-12-24T13:05:59.582Z" }, - { url = "https://files.pythonhosted.org/packages/04/68/18ecd1e4d54a523e1d077f01419d669116e5dede97f97f1eb8ddb918a872/djlint-1.36.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d68da0ed10ee9ca1e32e225cbb8e9b98bf7e6f8b48a8e4836117b6605b88cc7", size = 344261, upload-time = "2024-12-24T13:06:01.136Z" }, - { url = "https://files.pythonhosted.org/packages/1e/03/005cf5c66e57ca2d26249f8385bc64420b2a95fea81c5eb619c925199029/djlint-1.36.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c0478d5392247f1e6ee29220bbdbf7fb4e1bc0e7e83d291fda6fb926c1787ba7", size = 319580, upload-time = "2024-12-24T13:06:03.824Z" }, - { url = "https://files.pythonhosted.org/packages/9f/88/aea3c81343a273a87362f30442abc13351dc8ada0b10e51daa285b4dddac/djlint-1.36.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:962f7b83aee166e499eff916d631c6dde7f1447d7610785a60ed2a75a5763483", size = 407070, upload-time = "2024-12-24T13:06:05.356Z" }, - { url = "https://files.pythonhosted.org/packages/60/77/0f767ac0b72e9a664bb8c92b8940f21bc1b1e806e5bd727584d40a4ca551/djlint-1.36.4-cp311-cp311-win_amd64.whl", hash = "sha256:53cbc450aa425c832f09bc453b8a94a039d147b096740df54a3547fada77ed08", size = 360775, upload-time = "2024-12-24T13:06:10.176Z" }, - { url = "https://files.pythonhosted.org/packages/53/f5/9ae02b875604755d4d00cebf96b218b0faa3198edc630f56a139581aed87/djlint-1.36.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff9faffd7d43ac20467493fa71d5355b5b330a00ade1c4d1e859022f4195223b", size = 354886, upload-time = "2024-12-24T13:06:11.571Z" }, - { url = "https://files.pythonhosted.org/packages/97/51/284443ff2f2a278f61d4ae6ae55eaf820ad9f0fd386d781cdfe91f4de495/djlint-1.36.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:79489e262b5ac23a8dfb7ca37f1eea979674cfc2d2644f7061d95bea12c38f7e", size = 323237, upload-time = "2024-12-24T13:06:13.057Z" }, - { url = "https://files.pythonhosted.org/packages/6d/5e/791f4c5571f3f168ad26fa3757af8f7a05c623fde1134a9c4de814ee33b7/djlint-1.36.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e58c5fa8c6477144a0be0a87273706a059e6dd0d6efae01146ae8c29cdfca675", size = 411719, upload-time = "2024-12-24T13:06:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/1f/11/894425add6f84deffcc6e373f2ce250f2f7b01aa58c7f230016ebe7a0085/djlint-1.36.4-cp312-cp312-win_amd64.whl", hash = "sha256:bb6903777bf3124f5efedcddf1f4716aef097a7ec4223fc0fa54b865829a6e08", size = 362076, upload-time = "2024-12-24T13:06:17.517Z" }, - { url = "https://files.pythonhosted.org/packages/da/83/88b4c885812921739f5529a29085c3762705154d41caf7eb9a8886a3380c/djlint-1.36.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ead475013bcac46095b1bbc8cf97ed2f06e83422335734363f8a76b4ba7e47c2", size = 354384, upload-time = "2024-12-24T13:06:20.809Z" }, - { url = "https://files.pythonhosted.org/packages/32/38/67695f7a150b3d9d62fadb65242213d96024151570c3cf5d966effa68b0e/djlint-1.36.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6c601dfa68ea253311deb4a29a7362b7a64933bdfcfb5a06618f3e70ad1fa835", size = 322971, upload-time = "2024-12-24T13:06:22.185Z" }, - { url = "https://files.pythonhosted.org/packages/ac/7a/cd851393291b12e7fe17cf5d4d8874b8ea133aebbe9235f5314aabc96a52/djlint-1.36.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bda5014f295002363381969864addeb2db13955f1b26e772657c3b273ed7809f", size = 410972, upload-time = "2024-12-24T13:06:24.077Z" }, - { url = "https://files.pythonhosted.org/packages/6c/31/56469120394b970d4f079a552fde21ed27702ca729595ab0ed459eb6d240/djlint-1.36.4-cp313-cp313-win_amd64.whl", hash = "sha256:16ce37e085afe5a30953b2bd87cbe34c37843d94c701fc68a2dda06c1e428ff4", size = 362053, upload-time = "2024-12-24T13:06:25.432Z" }, - { url = "https://files.pythonhosted.org/packages/4b/67/f7aeea9be6fb3bd984487af8d0d80225a0b1e5f6f7126e3332d349fb13fe/djlint-1.36.4-py3-none-any.whl", hash = "sha256:e9699b8ac3057a6ed04fb90835b89bee954ed1959c01541ce4f8f729c938afdd", size = 52290, upload-time = "2024-12-24T13:06:33.76Z" }, + { url = "https://files.pythonhosted.org/packages/9e/df/a81550590ab37a3d99880b5dc781616f1944bd3b3e353bf041ee1d5fee7d/djlint-1.39.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc806fbc58d69941b5280f31f6126e0545f8408e99264d3a0dce1de767c8dd79", size = 520521, upload-time = "2026-06-05T19:23:12.411Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c3/c40a148a23d19fbeb9d1028e159fe4c16981c538d73beb9c4f28f0dd0e94/djlint-1.39.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a2621cfe40bd3cd439a028370b80ff8934fa6414f8cca1f27221957d1775b8fe", size = 496510, upload-time = "2026-06-05T19:22:44.882Z" }, + { url = "https://files.pythonhosted.org/packages/e8/35/4a9bf5e689146c98b4644dc85dc66b050d465cde5353ada06ad6fb3fd362/djlint-1.39.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4be236e58cac714bae3931970b4ae73425c200951803a8afd635f2a11a9463ac", size = 524712, upload-time = "2026-06-05T19:23:26.775Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fd/0d227699fb927136bece3df66638e0554f6eacb2bf9d3aea398402d97fe8/djlint-1.39.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f63e8cf847fcf748cadc7feb9acc265b89578fd043350c8776eb7b0825a0e5e9", size = 542959, upload-time = "2026-06-05T19:23:06.972Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fa/9badca5dc6d2bbae9cf81db959d887ea41f7333e9c8e87b0374175e85be4/djlint-1.39.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ab3427fa50149d0f618de08a437d24931ffce3e0505615556ed78e502edcb4d9", size = 529748, upload-time = "2026-06-05T19:22:56.673Z" }, + { url = "https://files.pythonhosted.org/packages/19/26/7f68a5b835451ababcf373830ba4068d5083ff2b06d9d423f3cf73fbf26f/djlint-1.39.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5800abf3d506708094497d55fdfbaae7b522b551c273ca22abeb5d682c28875e", size = 552687, upload-time = "2026-06-05T19:22:32.665Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6c/3c3252d8e6904db7ff4458545b48ae98af14a364cbfee1a7738c73386dd6/djlint-1.39.0-cp310-cp310-win32.whl", hash = "sha256:e69532cd9c970871ec475509fe41d8b5a453a51cd4a82b1e4f175f3144fa1a63", size = 405814, upload-time = "2026-06-05T19:22:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/eb/60/11fb512bd868161834f19fd088eb99e1c9a3cd024e0ba1fc3f28aa0b51d9/djlint-1.39.0-cp310-cp310-win_amd64.whl", hash = "sha256:b1ae7b0c3413adde6619dddf0ac58be55436489af482c45743caa9862282117b", size = 451264, upload-time = "2026-06-05T19:22:59.176Z" }, + { url = "https://files.pythonhosted.org/packages/ec/be/54d79236a7c29a373302ac5f0f3d92089003594dc02a40f58fc553e869fa/djlint-1.39.0-cp310-cp310-win_arm64.whl", hash = "sha256:9a15b5164b75544045c28a9950d29fb3b4992fd02217dae2a0607085547bb900", size = 388868, upload-time = "2026-06-05T19:23:25.724Z" }, + { url = "https://files.pythonhosted.org/packages/47/f6/3044a6be9d4ac207b39f001be7e0f6a695d007cfb4b4e45d761712cb23f9/djlint-1.39.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b9e02481c6725c7ec01ed4603bec6c8e8e8ffb9cd60280c10dba98d8edced43", size = 509864, upload-time = "2026-06-05T19:23:27.987Z" }, + { url = "https://files.pythonhosted.org/packages/ea/7b/30e67cfd1232d07ef3e6057e6017bfec6f08825aa08adc8cab5d3070cbe1/djlint-1.39.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c78334b22cf39d1f24c6e404eedab3f634f5eed70c8ce437762d47efdfa3a33e", size = 484321, upload-time = "2026-06-05T19:23:00.494Z" }, + { url = "https://files.pythonhosted.org/packages/bd/73/e1d5b0f3446123395c54a0084e85c4ceebc9d69b096c07ea11781df68db8/djlint-1.39.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08c67a870591e233604c5402163b44d4e872e801aa6a09fc082b4db33beb7049", size = 512772, upload-time = "2026-06-05T19:23:16.285Z" }, + { url = "https://files.pythonhosted.org/packages/89/8b/bb8a67ed58229511f0a137368c9e938e645d126946fbcaf98df8e1728e84/djlint-1.39.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fcaf7ef93dfde168b6d61ea9caf35f294232e5b72d2b6d401d04f1d753758435", size = 533865, upload-time = "2026-06-05T19:23:31.624Z" }, + { url = "https://files.pythonhosted.org/packages/fc/87/694fc944f94703ff8530dac13461dfff07354307d413265036b4cb6c2ecc/djlint-1.39.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bf9a88b60a521a7a795102ce16086745f6d8e1783d2517e87e13efb5cd3057d0", size = 520819, upload-time = "2026-06-05T19:23:03.786Z" }, + { url = "https://files.pythonhosted.org/packages/d2/db/5fcec4ed089d9d1d3a2967511cda5758a21153c346c7a645ac635e085d09/djlint-1.39.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:65d4008fcba8a3fb1550a37bee2271e13fd55f37e569122de6507e6c3a77bc10", size = 543638, upload-time = "2026-06-05T19:22:34.962Z" }, + { url = "https://files.pythonhosted.org/packages/a9/bb/9cb11cb40314573006b52a48870b51041c65fb3e33fe5e30b08dffe1bf6a/djlint-1.39.0-cp311-cp311-win32.whl", hash = "sha256:759a54d9fa426cb74b5ac02e55c3e6c22c8ce63401a2846cba302dad2888190a", size = 404853, upload-time = "2026-06-05T19:23:08.067Z" }, + { url = "https://files.pythonhosted.org/packages/8b/36/f79c30f9b83186a68e9977065fcbdac075547497fe253708ec73a517899a/djlint-1.39.0-cp311-cp311-win_amd64.whl", hash = "sha256:09fbaf395d88b8b372c284c25464d9bbc0f41fbb98444f3fc227773806c90fa1", size = 451186, upload-time = "2026-06-05T19:23:17.364Z" }, + { url = "https://files.pythonhosted.org/packages/41/1f/f05d094b2d2c192b2f32c12918a4dc0362723716a60254fd8cd3de95ef5c/djlint-1.39.0-cp311-cp311-win_arm64.whl", hash = "sha256:db91ccdbb475150038b0d272df6e8e1d8d4acc658990fa3484de4d5e46532f32", size = 387598, upload-time = "2026-06-05T19:23:30.422Z" }, + { url = "https://files.pythonhosted.org/packages/56/ef/dac918a5a78fe90f141f05d648db86e2033e39af8210b8e6d34a6c4c2c2a/djlint-1.39.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7e28a346d52ecccd580c577045c03711321c0ca6a0d224267a00f186695dbc1d", size = 520028, upload-time = "2026-06-05T19:22:42.688Z" }, + { url = "https://files.pythonhosted.org/packages/d9/dd/b1b2c34e43ac3fbf172cf0bed692a813284e1eecd335bdea8634318a6304/djlint-1.39.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:df5417ba6511a1655b50f1393ea6c1c8015bc20a5cbb27297b57ae78fe2d17ac", size = 491722, upload-time = "2026-06-05T19:23:14.683Z" }, + { url = "https://files.pythonhosted.org/packages/06/14/f010c3eb471f2c96a226af2eeaab634f032e1323e677fdd63cdcd981f173/djlint-1.39.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a3f45b96a84998e02049b3d555599da3c3e3254354c3401b52505a79aefbf59", size = 515560, upload-time = "2026-06-05T19:22:49.674Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8b/6cd7a60a494d156da84a07a92a77206f4ece42d24ed025b544b7d22eb98e/djlint-1.39.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa0acde91ce23f733e2b133b4db627a9dbd13f9cccceb0d55c6bcaa6556befc8", size = 539935, upload-time = "2026-06-05T19:23:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/ce/74/0b1353cbe9992d29cef42535b89726b92243c38e48683ee92bdde6dd65d2/djlint-1.39.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2f23eeb266efb075b738a45ab908594bbdaaee7ce20ab4dbc61c17501f70db16", size = 522470, upload-time = "2026-06-05T19:23:18.423Z" }, + { url = "https://files.pythonhosted.org/packages/8b/17/27995ca81db8af36b8469941e232adeab010e0a08c75b9622e03f755f5b5/djlint-1.39.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:adc646935fa1e3f6fec01a7eae471a1b41dddc6f2847a4402d3dc9d7483d5337", size = 548865, upload-time = "2026-06-05T19:22:45.92Z" }, + { url = "https://files.pythonhosted.org/packages/cf/60/c1ed2d49a54b1e28fcba790be28bb3a1fa7555cd9ea288c56e522118eb6b/djlint-1.39.0-cp312-cp312-win32.whl", hash = "sha256:5841c0cf72ded43e08bd97c9743116332c1b13a36d98cb7cc353c44ab86cf3b9", size = 407021, upload-time = "2026-06-05T19:23:01.642Z" }, + { url = "https://files.pythonhosted.org/packages/03/4b/3327b925dbb5244a06c76fec06e5abea9f53b9d78d254a4e1264124afc5a/djlint-1.39.0-cp312-cp312-win_amd64.whl", hash = "sha256:6f0db716dd94681e7dedf695563689249f7c470873f3f74b2765b7743783435e", size = 453876, upload-time = "2026-06-05T19:22:38.037Z" }, + { url = "https://files.pythonhosted.org/packages/da/dc/b690d59b6457fd2aab2cbcb66d93b9e95f0c3d04de81d5ef9a4b5cc9c545/djlint-1.39.0-cp312-cp312-win_arm64.whl", hash = "sha256:412f6319d9888548068af681c05722c9acfbe760d5b29d44832e1a40eb116be2", size = 388980, upload-time = "2026-06-05T19:22:39.228Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d0/6055cebb538718e46b3874d3a1c0c768aaf744a1354f342b1932985c882b/djlint-1.39.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fb2948211eb369bd28175f2007cc924bff7e2403ec1f42f22f6d4381c32bad31", size = 517087, upload-time = "2026-06-05T19:22:40.617Z" }, + { url = "https://files.pythonhosted.org/packages/39/be/726afcd62b9ce6382d2c10a9122a45daf4a47b6e2af4a7536c82b8b5f4fc/djlint-1.39.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e1476f077af638ba21813cc17d8e7d31b1d5473e707d98c659e6ac2bdf5210e6", size = 489869, upload-time = "2026-06-05T19:22:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/f26dc11c62111f6d80550e9188b2d207691f0664ed3b7dbd62ed5d418e32/djlint-1.39.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19dbef7852fabe445ce4ea2b05da888df0513e1798c4ae7cd8f0c68cf0bc8cbb", size = 513551, upload-time = "2026-06-05T19:23:13.49Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/2ffe28c44d27aa006314c1b352a0b6039ab05dd4b7b3dbac494315b912ab/djlint-1.39.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c8c7bba68633f6a4a211dd35ded9337ec52a7a2991afc816f928f741296c1b3", size = 537832, upload-time = "2026-06-05T19:22:30.67Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/2cb7966a7a93b4758a380500c9a18fa22688b071dba5b52106107b48de4e/djlint-1.39.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e5564bc51531332ba67bc8d952825ac2a42a7ec1618413a4da15bf957257c0d6", size = 520497, upload-time = "2026-06-05T19:23:19.497Z" }, + { url = "https://files.pythonhosted.org/packages/81/d0/b32648761b1529b030897b931998a6dabe6a15473c4724e1080c2ca737ae/djlint-1.39.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b836e79f690d83aa429cfa3240045e086f9e0764afbc88654004f455e2a9835a", size = 547304, upload-time = "2026-06-05T19:23:21.742Z" }, + { url = "https://files.pythonhosted.org/packages/26/6d/c0e7c61fdeee741ee7eec85a14dd40c8d2e1ee9efeb96a8a7302a8daef47/djlint-1.39.0-cp313-cp313-win32.whl", hash = "sha256:f18c148fc6cfb32dd8a0af7c80067f02d3faa83f5aea16a7c7fd5111d303ee69", size = 406746, upload-time = "2026-06-05T19:22:57.969Z" }, + { url = "https://files.pythonhosted.org/packages/74/c5/7ea676211bbb85665b2f82f2cc64925a4f54d866d57887ab943e97016fcf/djlint-1.39.0-cp313-cp313-win_amd64.whl", hash = "sha256:7c38a8e90f8a73adf08b6852ee34bf3c734873f2ff1df58e56206308272cb275", size = 453441, upload-time = "2026-06-05T19:22:41.662Z" }, + { url = "https://files.pythonhosted.org/packages/04/49/3056c368937e98d6cb7d1ac662e64e93bc9b5ddf5a2afcd01839c0095a51/djlint-1.39.0-cp313-cp313-win_arm64.whl", hash = "sha256:e95095623cf5d6e84161c9a08e81f29ea5f7f1c804107ccf7cd2fe27a750a3bc", size = 388639, upload-time = "2026-06-05T19:22:53.201Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c2/76fa9ffa5b88784a2704b64f08d902bc8071a99bdd79a983f56b3e2dfcdf/djlint-1.39.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a092b0beb93d9a6fe5e1e28934e4f933c483ce791aae9aec47e3f07a29511a61", size = 515957, upload-time = "2026-06-05T19:23:09.12Z" }, + { url = "https://files.pythonhosted.org/packages/d5/44/638b92e40ad5b473df6728c3c6c7ebd9d50823d4cf8dd5bdf22073bd1d57/djlint-1.39.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7ca3cd2c1ca610ad6e6357abba51e8153dc19f1d34764bcf453084199a4732a2", size = 488676, upload-time = "2026-06-05T19:22:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/27/b6/50e91d06554b74dc558a6af6349643c0165ff6dcc5142908ae2db012acca/djlint-1.39.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0011c2b78fa26752e3373129965dcbe80253af7fd2807e394fdfd4ea6281d99", size = 517217, upload-time = "2026-06-05T19:22:48.533Z" }, + { url = "https://files.pythonhosted.org/packages/77/2d/f9f900ae26b44b3b79090667148eeb016464cfe70d0211e2afe0fda9ab4c/djlint-1.39.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:683ec039c2864670f1806fc96e4650f3f7e310222acb5d602608aeb24ca352e9", size = 537472, upload-time = "2026-06-05T19:22:51.868Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ad/28ef34f629e728042341c397261fc2593a2eec489e44a7863cf646edc628/djlint-1.39.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:326a5ec019b084eb2d837f39d0bea6727806867e9d1e26d3f4bf0cd6bc67bf8f", size = 523546, upload-time = "2026-06-05T19:23:29.143Z" }, + { url = "https://files.pythonhosted.org/packages/3c/6a/7ce68fdf319d9abda560fe3509d60abefe25ef118ae21d03399b1dfc84e7/djlint-1.39.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e655ac4e4346b3f5a61b53a9351104d33e4a7376f1c22acf4fadf1183f90128a", size = 546627, upload-time = "2026-06-05T19:22:31.67Z" }, + { url = "https://files.pythonhosted.org/packages/04/89/3e5bfaeb7b39a078a9a8d4fc7331e60f12f0e5c1251bc6c622be8c592ad4/djlint-1.39.0-cp314-cp314-win32.whl", hash = "sha256:0b5e30ab98c4de74698211ce6a60a502307d176015bf98269f74a39d862fc694", size = 412745, upload-time = "2026-06-05T19:22:35.955Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bd/b891316176513c233507dbf2f82747552e401079e3f917c46fbf84c5ef05/djlint-1.39.0-cp314-cp314-win_amd64.whl", hash = "sha256:9d4927b1bf65445e3c8dda8d1b96ab3019dbce1eaa88850760df78962bf2724e", size = 462295, upload-time = "2026-06-05T19:23:05.893Z" }, + { url = "https://files.pythonhosted.org/packages/06/44/ba3bf57ee70e969407e96d7accfb13d00c776674dbce95f8b07e1c7f731f/djlint-1.39.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b6a684f5cd8fc71ad55cd3c1acffa0cd4108bc63ad1524f9ca1d76b1b354e47", size = 396557, upload-time = "2026-06-05T19:22:54.276Z" }, + { url = "https://files.pythonhosted.org/packages/26/c0/bdb3eb96bd8e5d65546fe63063b787e302b981ec2f1436b1a0027404c311/djlint-1.39.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:afa4ba49d6b67f3c0145d78448c292e75d5822e76c189ef681399ead8492c599", size = 561022, upload-time = "2026-06-05T19:23:23.09Z" }, + { url = "https://files.pythonhosted.org/packages/96/98/e35b87ebc8f2a6985aed5ea7b85145d9e6e5d5b67fc3b612396a84604791/djlint-1.39.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1fee96af514bd1cb6b62d1107bb177d4d2f49361e5e9cd14f56f9650cdc2b5ad", size = 534450, upload-time = "2026-06-05T19:22:33.683Z" }, + { url = "https://files.pythonhosted.org/packages/87/f4/3ff2615cc2826c91ec3c7c26e8abedb35b3a546a068bc70ef385b2079c17/djlint-1.39.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ef06848e1ed5d987bb1aaf950ffe3a87b14e5937d9d42dbb1d0469ebe7a74dc", size = 552149, upload-time = "2026-06-05T19:22:27.861Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fc/6fea3ea0075d06d1d5444a7ad72bf51c612795339e95d4b281599c61b9ee/djlint-1.39.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffcbca30ad41bc054c7c7ed5341ea651b034a60d4eff0aa2ab0bb8cb40f2b9b0", size = 570693, upload-time = "2026-06-05T19:22:55.293Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6a/af8a4012652a33208b3e0ca04c23446711fa5ecf8936809c04c6213c47b8/djlint-1.39.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8aace5a239e5f317b030a5c05d22d55edac5142366ffa1a15e5e5c8675044e44", size = 557296, upload-time = "2026-06-05T19:23:24.545Z" }, + { url = "https://files.pythonhosted.org/packages/6e/13/bf86a4f5d140ab6052a3aca8742cb446ec851946c7dcb625eb18a2564893/djlint-1.39.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9912c361968a3c881fd3eaff5a5dc56a0a409a7904355d998d430ff294550744", size = 579052, upload-time = "2026-06-05T19:23:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/5d2850606e321f8d6e56fe74fcb283c12493d179279bb52f347d0338aa6e/djlint-1.39.0-cp314-cp314t-win32.whl", hash = "sha256:12d3175f48317ec692da693a15ce7b939b3114f16b8d644bb037784bcef0bd52", size = 457432, upload-time = "2026-06-05T19:23:04.728Z" }, + { url = "https://files.pythonhosted.org/packages/f4/9f/6dc179c101d30c1aa4269e0cada79667c043d15392e515fb7e4e36e8a8df/djlint-1.39.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a3077dc9a4b3bb2724cd0231f008d309fe4ef4048af06b7edd1adba723356248", size = 513546, upload-time = "2026-06-05T19:23:11.375Z" }, + { url = "https://files.pythonhosted.org/packages/d9/0d/e3acb7da4ce3df5d699412b9442b885286df7e45647c205d65e593d02711/djlint-1.39.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f7228e01d5ceaf74fb5270d7bdfbd30dffe65e88216a70824765bca6acb2a4fb", size = 412286, upload-time = "2026-06-05T19:22:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/bd/45/50bddcbcee9566c213f14db5b154ade285c4842b88cdcdcc8d536d515147/djlint-1.39.0-py3-none-any.whl", hash = "sha256:3ef41f7bbf7761978e86e24ebdaf58704b17d847e9d0b5d9cb9f761ce976cff0", size = 60750, upload-time = "2026-06-05T19:23:02.846Z" }, ] [[package]] @@ -100,7 +138,7 @@ dev = [ [package.metadata.requires-dev] dev = [ - { name = "djlint", specifier = "==1.36.4" }, + { name = "djlint", specifier = "==1.39.0" }, { name = "yamllint", specifier = "==1.38.0" }, { name = "zizmor", specifier = "==1.25.2" }, ] diff --git a/web_src/css/index.css b/web_src/css/index.css index 2d3e118825..71e58e7c8e 100644 --- a/web_src/css/index.css +++ b/web_src/css/index.css @@ -52,6 +52,7 @@ @import "./markup/content.css"; @import "./markup/codeblock.css"; @import "./markup/codepreview.css"; +@import "./markup/jupyter.css"; @import "./font_i18n.css"; @import "./base.css"; diff --git a/web_src/css/markup/jupyter.css b/web_src/css/markup/jupyter.css new file mode 100644 index 0000000000..ab128e64a7 --- /dev/null +++ b/web_src/css/markup/jupyter.css @@ -0,0 +1,93 @@ +.markup.jupyter-render { + padding: 0; +} + +.markup .jupyter-notebook { + padding: 20px; + background: var(--color-body); + border-bottom-left-radius: var(--border-radius); + border-bottom-right-radius: var(--border-radius); + font-family: var(--fonts-monospace); + display: flex; + flex-direction: column; + gap: 2em; +} + +/* cell code */ +.markup .jupyter-notebook .cell-line { + display: flex; + width: 100%; + gap: 0.5em; +} + +.markup .jupyter-notebook .cell-left { + width: 100px; + flex-shrink: 0; +} + +.markup .jupyter-notebook .cell-right { + flex: 1; +} + +.markup .jupyter-notebook .cell-prompt { + padding: 10px 0; + color: var(--color-text-light-2); + font-size: 13px; +} + +.markup .jupyter-notebook .cell-left.cell-prompt { + padding-left: 10px; + text-align: right; + white-space: nowrap; + user-select: none; +} + +.markup .jupyter-notebook .cell-right.cell-prompt { + padding-right: 10px; +} + +.markup .jupyter-notebook .cell-input, +.markup .jupyter-notebook .cell-output { + overflow-x: auto; +} + +.markup .jupyter-notebook .cell-input pre, +.markup .jupyter-notebook .cell-output pre { + padding: 10px 16px; + font-size: 13px; + min-height: 40px; + margin: 0; +} + +.markup .jupyter-notebook .cell-input pre { + background-color: var(--color-code-bg); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.markup .jupyter-notebook .cell-output { + display: flex; + flex-direction: column; + gap: 1em; +} + +.markup .jupyter-notebook .cell-type-code { + display: flex; + flex-direction: column; + gap: 1em; +} + +.markup .jupyter-notebook .cell-output-unsupported { + color: var(--color-text-light-2); + font-style: italic; + font-size: 13px; +} + +.markup .jupyter-notebook .cell-output-error { + color: var(--color-red); +} + +/* cell markdown */ +.markup .jupyter-notebook .cell-right .embedded-markdown { + padding: 0 16px; /* match cell code right padding */ +} diff --git a/web_src/css/repo/file-view.css b/web_src/css/repo/file-view.css index 3f1c42a4a1..fec1c7cd8f 100644 --- a/web_src/css/repo/file-view.css +++ b/web_src/css/repo/file-view.css @@ -1,3 +1,9 @@ +.file-view-container { + padding: 0 !important; /* the file-view itself provides padding */ + width: 100% !important; /* override fomantic's "100% + 2px" */ + max-width: 100% !important; +} + .file-view tr.active .lines-num, .file-view tr.active .lines-escape, .file-view tr.active .lines-code { diff --git a/web_src/js/components/ActionStatusIcon.vue b/web_src/js/components/ActionStatusIcon.vue index 12da669ac4..bebb510467 100644 --- a/web_src/js/components/ActionStatusIcon.vue +++ b/web_src/js/components/ActionStatusIcon.vue @@ -2,32 +2,33 @@ action status accepted: success, skipped, waiting, blocked, running, failure, cancelled, cancelling, unknown. --> diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index 03d62bd7cc..9ce926673b 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -1,7 +1,8 @@