mirror of
https://github.com/go-gitea/gitea.git
synced 2026-06-15 20:25:18 +02:00
Compare commits
6 Commits
b8ef6a91e6
...
a77edc7ba4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a77edc7ba4 | ||
|
|
55250407dd | ||
|
|
80ca22a9ef | ||
|
|
47d48eb208 | ||
|
|
3417bc8979 | ||
|
|
c6167d1ff5 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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, ", "))
|
||||
|
||||
@@ -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 "<value> <ConstName>[ <free text>]"; 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]
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
36
models/migrations/v1_27/v337.go
Normal file
36
models/migrations/v1_27/v337.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)"`
|
||||
}
|
||||
|
||||
31
modules/structs/token.go
Normal file
31
modules/structs/token.go
Normal file
@@ -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"`
|
||||
}
|
||||
@@ -2865,6 +2865,14 @@
|
||||
"org.teams.all_repositories_read_permission_desc": "This team grants <strong>Read</strong> access to <strong>all repositories</strong>: members can view and clone repositories.",
|
||||
"org.teams.all_repositories_write_permission_desc": "This team grants <strong>Write</strong> access to <strong>all repositories</strong>: members can read from and push to repositories.",
|
||||
"org.teams.all_repositories_admin_permission_desc": "This team grants <strong>Admin</strong> access to <strong>all repositories</strong>: 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 <strong>%s</strong> in organization <strong>%s</strong>.",
|
||||
"org.teams.invite.by": "Invited by %s",
|
||||
"org.teams.invite.description": "Please click the button below to join the team.",
|
||||
|
||||
@@ -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.
|
||||
@@ -1697,25 +1752,31 @@ func Routes() *web.Router {
|
||||
}, reqToken(), reqOrgOwnership())
|
||||
}, 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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
88
routers/api/v1/token/token.go
Normal file
88
routers/api/v1/token/token.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
{{template "base/alert" .}}
|
||||
<div class="required field {{if .Err_TeamName}}error{{end}}">
|
||||
<label for="team_name">{{ctx.Locale.Tr "org.team_name"}}</label>
|
||||
{{if eq .Team.LowerName "owners"}}
|
||||
{{if .Team.IsOwnerTeam}}
|
||||
<input type="hidden" name="team_name" value="{{.Team.Name}}">
|
||||
{{end}}
|
||||
<input id="team_name" name="team_name" value="{{.Team.Name}}" required {{if eq .Team.LowerName "owners"}}disabled{{end}} autofocus>
|
||||
<input id="team_name" name="team_name" value="{{.Team.Name}}" required {{if .Team.IsOwnerTeam}}disabled{{end}} autofocus>
|
||||
<span class="help">{{ctx.Locale.Tr "org.team_name_helper"}}</span>
|
||||
</div>
|
||||
<div class="field {{if .Err_Description}}error{{end}}">
|
||||
@@ -23,7 +23,47 @@
|
||||
<input id="description" name="description" value="{{.Team.Description}}" maxlength="255">
|
||||
<span class="help">{{ctx.Locale.Tr "org.team_desc_helper"}}</span>
|
||||
</div>
|
||||
{{if not (eq .Team.LowerName "owners")}}
|
||||
{{if .Team.IsOwnerTeam}}
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.teams.visibility"}}</label>
|
||||
<div class="tw-mb-1">
|
||||
{{if .Team.IsPrivate}}
|
||||
<span class="ui mini label">{{ctx.Locale.Tr "org.teams.visibility_private"}}</span>
|
||||
{{else if .Team.IsLimited}}
|
||||
<span class="ui mini label">{{ctx.Locale.Tr "org.teams.visibility_limited"}}</span>
|
||||
{{else if .Team.IsPublic}}
|
||||
<span class="ui mini label">{{ctx.Locale.Tr "org.teams.visibility_public"}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<span class="help">{{ctx.Locale.Tr "org.teams.owners_visibility_fixed"}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if not .Team.IsOwnerTeam}}
|
||||
<div class="grouped field">
|
||||
<label>{{ctx.Locale.Tr "org.teams.visibility"}}</label>
|
||||
<br>
|
||||
<div class="field">
|
||||
<div class="ui radio checkbox">
|
||||
<input type="radio" name="visibility" value="private" {{if or .PageIsOrgTeamsNew .Team.IsPrivate}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "org.teams.visibility_private"}}</label>
|
||||
<span class="help">{{ctx.Locale.Tr "org.teams.visibility_private_helper"}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui radio checkbox">
|
||||
<input type="radio" name="visibility" value="limited" {{if .Team.IsLimited}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "org.teams.visibility_limited"}}</label>
|
||||
<span class="help">{{ctx.Locale.Tr "org.teams.visibility_limited_helper"}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui radio checkbox">
|
||||
<input type="radio" name="visibility" value="public" {{if .Team.IsPublic}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "org.teams.visibility_public"}}</label>
|
||||
<span class="help">{{ctx.Locale.Tr "org.teams.visibility_public_helper"}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grouped field">
|
||||
<label>{{ctx.Locale.Tr "org.team_access_desc"}}</label>
|
||||
<br>
|
||||
@@ -135,7 +175,7 @@
|
||||
<button class="ui primary button">{{ctx.Locale.Tr "org.create_team"}}</button>
|
||||
{{else}}
|
||||
<button class="ui primary button">{{ctx.Locale.Tr "org.teams.update_settings"}}</button>
|
||||
{{if not (eq .Team.LowerName "owners")}}
|
||||
{{if not .Team.IsOwnerTeam}}
|
||||
<button class="ui red button delete-button" data-url="{{.OrgLink}}/teams/{{.Team.Name | PathEscape}}/delete">{{ctx.Locale.Tr "org.teams.delete_team"}}</button>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
<div class="ui six wide column">
|
||||
<h4 class="ui top attached header flex-left-right">
|
||||
<strong>{{.Team.Name}}</strong>
|
||||
<div class="flex-text-inline">
|
||||
<strong>{{.Team.Name}}</strong>
|
||||
{{if .Team.IsPrivate}}
|
||||
<span class="ui mini label" data-tooltip-content="{{ctx.Locale.Tr "org.teams.visibility_private_helper"}}">{{ctx.Locale.Tr "org.teams.visibility_private"}}</span>
|
||||
{{else if .Team.IsLimited}}
|
||||
<span class="ui mini label" data-tooltip-content="{{ctx.Locale.Tr "org.teams.visibility_limited_helper"}}">{{ctx.Locale.Tr "org.teams.visibility_limited"}}</span>
|
||||
{{else if .Team.IsPublic}}
|
||||
<span class="ui mini label" data-tooltip-content="{{ctx.Locale.Tr "org.teams.visibility_public_helper"}}">{{ctx.Locale.Tr "org.teams.visibility_public"}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="flex-text-block">
|
||||
{{if .Team.IsMember ctx $.SignedUser.ID}}
|
||||
<button class="ui red mini compact button show-modal" data-modal="#org-member-leave-team"
|
||||
@@ -26,7 +35,7 @@
|
||||
|
||||
<div class="ui attached segment">
|
||||
{{/* TODO: old indent is kept to make diff changes minimal, can be reformatted in the future */}}
|
||||
{{if eq .Team.LowerName "owners"}}
|
||||
{{if .Team.IsOwnerTeam}}
|
||||
<p>{{ctx.Locale.Tr "org.teams.owners_permission_desc"}}</p>
|
||||
<p>{{ctx.Locale.Tr "org.teams.owners_permission_suggestion"}}</p>
|
||||
{{else}}
|
||||
|
||||
@@ -21,7 +21,16 @@
|
||||
{{range $team := $.OrgListTeams}}
|
||||
<div class="column team-item-box">
|
||||
<div class="ui top attached header muted-links flex-left-right team-item-header">
|
||||
<a href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}"><strong>{{.Name}}</strong></a>
|
||||
<div class="flex-text-inline">
|
||||
<a href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}"><strong>{{.Name}}</strong></a>
|
||||
{{if .IsPrivate}}
|
||||
<span class="ui mini label" data-tooltip-content="{{ctx.Locale.Tr "org.teams.visibility_private_helper"}}">{{ctx.Locale.Tr "org.teams.visibility_private"}}</span>
|
||||
{{else if .IsLimited}}
|
||||
<span class="ui mini label" data-tooltip-content="{{ctx.Locale.Tr "org.teams.visibility_limited_helper"}}">{{ctx.Locale.Tr "org.teams.visibility_limited"}}</span>
|
||||
{{else if .IsPublic}}
|
||||
<span class="ui mini label" data-tooltip-content="{{ctx.Locale.Tr "org.teams.visibility_public_helper"}}">{{ctx.Locale.Tr "org.teams.visibility_public"}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="flex-text-block tw-flex-wrap">
|
||||
<a href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}">{{.NumMembers}} {{ctx.Locale.Tr "org.lower_members"}}</a>
|
||||
·
|
||||
|
||||
@@ -78,7 +78,10 @@
|
||||
</a>
|
||||
{{range .StatusInfoList}}
|
||||
<a class="item{{if eq .Status $.CurStatus}} selected{{end}}" href="?workflow={{$.CurWorkflow}}&actor={{$.CurActor}}&status={{.Status}}&branch={{$.CurBranch}}">
|
||||
{{.DisplayedStatus}}
|
||||
<span class="flex-text-inline tw-gap-2">
|
||||
{{template "repo/icons/action_status" (dict "Status" .StatusName)}}
|
||||
{{.DisplayedStatus}}
|
||||
</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
130
templates/swagger/v1_json.tmpl
generated
130
templates/swagger/v1_json.tmpl
generated
@@ -19202,6 +19202,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": [
|
||||
@@ -24960,6 +24992,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"
|
||||
@@ -25116,6 +25159,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",
|
||||
@@ -26117,6 +26201,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"
|
||||
@@ -30023,6 +30118,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"
|
||||
@@ -30585,6 +30691,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",
|
||||
@@ -31089,6 +31213,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"CurrentAccessToken": {
|
||||
"description": "CurrentAccessToken represents the currently authenticated access token.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/CurrentAccessToken"
|
||||
}
|
||||
},
|
||||
"DeployKey": {
|
||||
"description": "DeployKey",
|
||||
"schema": {
|
||||
|
||||
127
templates/swagger/v1_openapi3_json.tmpl
generated
127
templates/swagger/v1_openapi3_json.tmpl
generated
@@ -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": [
|
||||
@@ -9887,11 +9954,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",
|
||||
@@ -10454,6 +10537,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": {
|
||||
@@ -31385,6 +31486,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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
98
tests/integration/api_token_self_test.go
Normal file
98
tests/integration/api_token_self_test.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user