diff --git a/docs/community-governance.md b/docs/community-governance.md index 0d9a9835a78..90ba435628c 100644 --- a/docs/community-governance.md +++ b/docs/community-governance.md @@ -164,7 +164,12 @@ Mergers are the maintainers who carry out the final merge of approved PRs. Their #### Becoming a merger -A merger should already be a Gitea maintainer. To apply, use the [Discord](https://discord.gg/Gitea) `#maintainers` channel. Mergers teams may also invite contributors. +A merger must already be a Gitea maintainer. +To apply, use the [Discord](https://discord.gg/Gitea) `#maintainers` channel. +The minimum requirement for applications to become a merger is to have participated actively in the community for at least four months before applying. +Ultimately, regardless of previous participation, you can only become a merger if the TOC votes in your favor. + +You may also be invited by the TOC to become a merger. ### Technical Oversight Committee (TOC) @@ -185,17 +190,30 @@ As long as seats are empty in the TOC, members of the previous TOC can fill them If an elected member that accepts the seat does not have 2FA configured yet, they will be temporarily counted as `answer pending` until they manage to configure 2FA, thus leaving their seat empty for this duration. +If multiple persons have the same amount of votes, a random draw will be used to determine the order of the candidates with the same amount of votes, and thus who gets the seat first. +The candidates will be placed in the list in an alphabetical insensitive order by their username. +We use this script to determine the order of candidates with the same amount of votes: + +```python +import random +random.seed("Gitea TOC Election") +random.choice([, , ...]) +``` + +The result of this script needs then to be published in the TOC election issue to ensure transparency of the process. + ### Current TOC members -- 2025-01-01 ~ 2026-06-14 +- 2026-06-14 ~ 2026-12-31 - Company - [Jason Song](https://gitea.com/wolfogre) - [Lunny Xiao](https://gitea.com/lunny) - [Matti Ranta](https://gitea.com/techknowlogick) - Community - - [6543](https://gitea.com/6543) <6543@obermui.de> + - [bircni](https://gitea.com/bircni) - [delvh](https://gitea.com/delvh) - - [lafriks](https://gitea.com/lafriks) + - [TheFox0x7](https://gitea.com/TheFox0x7) + ### Previous TOC/owners members @@ -207,9 +225,10 @@ Here's the history of the owners and the time they served: - [Lauris Bukšis-Haberkorns](https://gitea.com/lafriks) - [2018](https://github.com/go-gitea/gitea/issues/3255), [2019](https://github.com/go-gitea/gitea/issues/5572), [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801) - [Matti Ranta](https://gitea.com/techknowlogick) - [2019](https://github.com/go-gitea/gitea/issues/5572), [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801), [2022](https://github.com/go-gitea/gitea/issues/17872), 2023 - [Andrew Thornton](https://gitea.com/zeripath) - [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801), [2022](https://github.com/go-gitea/gitea/issues/17872), 2023 -- [6543](https://gitea.com/6543) - 2023 +- [6543](https://gitea.com/6543) - 2023, 2025 - [John Olheiser](https://gitea.com/jolheiser) - 2023, 2024 - [Jason Song](https://gitea.com/wolfogre) - 2023 +- [lafriks](https://gitea.com/lafriks) - 2025 ## Governance Compensation diff --git a/main.go b/main.go index 80b8a51d4a4..0a3d0164fa2 100644 --- a/main.go +++ b/main.go @@ -17,6 +17,7 @@ import ( // register supported doc types _ "gitea.dev/modules/markup/console" _ "gitea.dev/modules/markup/csv" + _ "gitea.dev/modules/markup/jupyter" _ "gitea.dev/modules/markup/markdown" _ "gitea.dev/modules/markup/orgmode" diff --git a/models/auth/access_token.go b/models/auth/access_token.go index da451fb044d..63a345dfcdc 100644 --- a/models/auth/access_token.go +++ b/models/auth/access_token.go @@ -20,42 +20,6 @@ import ( "xorm.io/builder" ) -// ErrAccessTokenNotExist represents a "AccessTokenNotExist" kind of error. -type ErrAccessTokenNotExist struct { - Token string -} - -// IsErrAccessTokenNotExist checks if an error is a ErrAccessTokenNotExist. -func IsErrAccessTokenNotExist(err error) bool { - _, ok := err.(ErrAccessTokenNotExist) - return ok -} - -func (err ErrAccessTokenNotExist) Error() string { - return fmt.Sprintf("access token does not exist [sha: %s]", err.Token) -} - -func (err ErrAccessTokenNotExist) Unwrap() error { - return util.ErrNotExist -} - -// ErrAccessTokenEmpty represents a "AccessTokenEmpty" kind of error. -type ErrAccessTokenEmpty struct{} - -// IsErrAccessTokenEmpty checks if an error is a ErrAccessTokenEmpty. -func IsErrAccessTokenEmpty(err error) bool { - _, ok := err.(ErrAccessTokenEmpty) - return ok -} - -func (err ErrAccessTokenEmpty) Error() string { - return "access token is empty" -} - -func (err ErrAccessTokenEmpty) Unwrap() error { - return util.ErrInvalidArgument -} - var successfulAccessTokenCache *lru.Cache[string, any] // AccessToken represents a personal access token. @@ -134,21 +98,11 @@ func getAccessTokenIDFromCache(token string) int64 { // GetAccessTokenBySHA returns access token by given token value func GetAccessTokenBySHA(ctx context.Context, token string) (*AccessToken, error) { - if token == "" { - return nil, ErrAccessTokenEmpty{} - } - // A token is defined as being SHA1 sum these are 40 hexadecimal bytes long - if len(token) != 40 { - return nil, ErrAccessTokenNotExist{token} - } - for _, x := range []byte(token) { - if x < '0' || (x > '9' && x < 'a') || x > 'f' { - return nil, ErrAccessTokenNotExist{token} - } + if len(token) < 8 { + return nil, util.NewNotExistErrorf("access token not found") } lastEight := token[len(token)-8:] - if id := getAccessTokenIDFromCache(token); id > 0 { accessToken := &AccessToken{ TokenLastEight: lastEight, @@ -169,7 +123,7 @@ func GetAccessTokenBySHA(ctx context.Context, token string) (*AccessToken, error if err != nil { return nil, err } else if len(tokens) == 0 { - return nil, ErrAccessTokenNotExist{token} + return nil, util.NewNotExistErrorf("access token not found") } for _, t := range tokens { @@ -181,7 +135,7 @@ func GetAccessTokenBySHA(ctx context.Context, token string) (*AccessToken, error return &t, nil } } - return nil, ErrAccessTokenNotExist{token} + return nil, util.NewNotExistErrorf("access token not found") } // AccessTokenByNameExists checks if a token name has been used already by a user. @@ -218,13 +172,11 @@ func UpdateAccessToken(ctx context.Context, t *AccessToken) error { // DeleteAccessTokenByID deletes access token by given ID. func DeleteAccessTokenByID(ctx context.Context, id, userID int64) error { - cnt, err := db.GetEngine(ctx).ID(id).Delete(&AccessToken{ - UID: userID, - }) + cnt, err := db.GetEngine(ctx).ID(id).Delete(&AccessToken{UID: userID}) if err != nil { return err } else if cnt != 1 { - return ErrAccessTokenNotExist{} + return util.NewNotExistErrorf("access token not found") } return nil } diff --git a/models/auth/access_token_test.go b/models/auth/access_token_test.go index 504600cd087..acab8b3ab50 100644 --- a/models/auth/access_token_test.go +++ b/models/auth/access_token_test.go @@ -9,6 +9,7 @@ import ( auth_model "gitea.dev/models/auth" "gitea.dev/models/db" "gitea.dev/models/unittest" + "gitea.dev/modules/util" "github.com/stretchr/testify/assert" ) @@ -76,11 +77,11 @@ func TestGetAccessTokenBySHA(t *testing.T) { _, err = auth_model.GetAccessTokenBySHA(t.Context(), "notahash") assert.Error(t, err) - assert.True(t, auth_model.IsErrAccessTokenNotExist(err)) + assert.ErrorIs(t, err, util.ErrNotExist) _, err = auth_model.GetAccessTokenBySHA(t.Context(), "") assert.Error(t, err) - assert.True(t, auth_model.IsErrAccessTokenEmpty(err)) + assert.ErrorIs(t, err, util.ErrNotExist) } func TestListAccessTokens(t *testing.T) { @@ -128,5 +129,5 @@ func TestDeleteAccessTokenByID(t *testing.T) { err = auth_model.DeleteAccessTokenByID(t.Context(), 100, 100) assert.Error(t, err) - assert.True(t, auth_model.IsErrAccessTokenNotExist(err)) + assert.ErrorIs(t, err, util.ErrNotExist) } diff --git a/modules/htmlutil/html.go b/modules/htmlutil/html.go index 5db1d6404ad..a480826bc61 100644 --- a/modules/htmlutil/html.go +++ b/modules/htmlutil/html.go @@ -4,6 +4,7 @@ package htmlutil import ( + "errors" "fmt" "html/template" "io" @@ -88,6 +89,52 @@ func EscapeString(s string) template.HTML { return template.HTML(template.HTMLEscapeString(s)) } +type HTMLWriter interface { + OriginWriter() io.Writer + WriteString(s string) HTMLWriter + WriteHTML(s template.HTML) HTMLWriter + WriteFormat(fmt template.HTML, args ...any) HTMLWriter + Err() error +} + +type htmlWriter struct { + w io.Writer + errs []error +} + +func (h *htmlWriter) OriginWriter() io.Writer { + return h.w +} + +func (h *htmlWriter) WriteString(s string) HTMLWriter { + if _, err := io.WriteString(h.w, template.HTMLEscapeString(s)); err != nil { + h.errs = append(h.errs, err) + } + return h +} + +func (h *htmlWriter) WriteHTML(s template.HTML) HTMLWriter { + if _, err := io.WriteString(h.w, string(s)); err != nil { + h.errs = append(h.errs, err) + } + return h +} + +func (h *htmlWriter) WriteFormat(fmt template.HTML, args ...any) HTMLWriter { + if _, err := HTMLPrintf(h.w, fmt, args...); err != nil { + h.errs = append(h.errs, err) + } + return h +} + +func (h *htmlWriter) Err() error { + return errors.Join(h.errs...) +} + +func NewHTMLWriter(w io.Writer) HTMLWriter { + return &htmlWriter{w: w} +} + type HTMLBuilder struct { sb strings.Builder } diff --git a/modules/htmlutil/html_test.go b/modules/htmlutil/html_test.go index a1ab0a6a49b..88e4935a1df 100644 --- a/modules/htmlutil/html_test.go +++ b/modules/htmlutil/html_test.go @@ -5,6 +5,7 @@ package htmlutil import ( "html/template" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -29,3 +30,11 @@ func TestHTMLBuilder(t *testing.T) { assert.Equal(t, "<
>>", b.String()) assert.Equal(t, template.HTML("<
>>"), b.HTMLString()) } + +func TestHTMLWriter(t *testing.T) { + sb := new(strings.Builder) + w := NewHTMLWriter(sb) + w.WriteString("<").WriteHTML("
").WriteFormat("%s%s", ">", EscapeString(">")) + assert.Equal(t, "<
>>", sb.String()) + assert.NoError(t, w.Err()) +} diff --git a/modules/markup/jupyter/jupyter-test.ipynb b/modules/markup/jupyter/jupyter-test.ipynb new file mode 100644 index 00000000000..1bb45f99584 --- /dev/null +++ b/modules/markup/jupyter/jupyter-test.ipynb @@ -0,0 +1,74 @@ +{ + "metadata": {}, + "nbformat": 4, + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "source": ["print('very-looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong')"], + "outputs": [ + { + "output_type": "execute_result", + "text": ["very-looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong ...\n"] + }, + { + "output_type": "stream", + "name": "stdout", + "text": ["stdout 1 ...\n", "stdout 2 ...\n"] + }, + { + "output_type": "stream", + "name": "stderr", + "text": ["stderr ...\n"] + }, + { + "data": { + "text/plain": ["data text 1\n", "data text 2\n"] + } + }, + { + "data": { + "text/plain": true + } + }, + { + "data": { + "image/svg+xml": [""] + } + }, + { + "data": { + "text/html": "HTML Link" + } + }, + { + "data": { + "text/latex": "$$a=1$$" + } + }, + { + "data": { + "text/plain": "plain text" + } + }, + { + "output_type": "error", + "ename": "Error Name", + "traceback": ["stacktrace 1", "stacktrace 2"] + } + ] + }, + { + "cell_type": "unknown-cell" + }, + { + "cell_type": "markdown", + "source": [ + "# h1\n", "## h2\n", "### h3\n", "\n", "paragraph 1\n", "\n", + "very-looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong\n", + "- list item 1\n", "- list item 2\n", "\n", "```python\n", "print('code block')\n", "```\n", + "
th1th2
td1td2
\n" + ] + } + ] +} diff --git a/modules/markup/jupyter/jupyter.go b/modules/markup/jupyter/jupyter.go new file mode 100644 index 00000000000..059046c1365 --- /dev/null +++ b/modules/markup/jupyter/jupyter.go @@ -0,0 +1,393 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package jupyter + +import ( + "encoding/base64" + "fmt" + "io" + "strings" + "sync" + + "gitea.dev/modules/highlight" + "gitea.dev/modules/htmlutil" + "gitea.dev/modules/json" + "gitea.dev/modules/log" + "gitea.dev/modules/markup" + "gitea.dev/modules/markup/markdown" + "gitea.dev/modules/setting" + "gitea.dev/modules/util" +) + +func init() { + markup.RegisterRenderer(renderer{}) +} + +// Renderer implements markup.Renderer for Jupyter notebooks +type renderer struct{} + +var ( + _ markup.Renderer = (*renderer)(nil) + _ markup.PostProcessRenderer = (*renderer)(nil) + _ markup.ExternalRenderer = (*renderer)(nil) // FIXME: this is not an external render, need to refactor the framework in the future +) + +type mimeHandler struct { + Mime string + Fn func(w htmlutil.HTMLWriter, data string) error +} + +func renderCellCodeOutputTextPlain(w htmlutil.HTMLWriter, text string) error { + w.WriteFormat(`
%s
`, text) + return w.Err() +} + +func renderCellCodeOutputUnsupported(w htmlutil.HTMLWriter, message string) error { + w.WriteFormat(`
%s
`, message) + return w.Err() +} + +var dataMimeHandlers = sync.OnceValue(func() []mimeHandler { + renderImage := func(w htmlutil.HTMLWriter, subtype, payload string) error { + w.WriteFormat(`
`, subtype, payload) + return w.Err() + } + renderUnsupportedOutput := func(message string) func(htmlutil.HTMLWriter, string) error { + return func(w htmlutil.HTMLWriter, _ string) error { + return renderCellCodeOutputUnsupported(w, message) + } + } + return []mimeHandler{ + // Images (PNG, JPEG, SVG) + {"image/png", func(w htmlutil.HTMLWriter, d string) error { + return renderImage(w, "png", d) + }}, + {"image/jpeg", func(w htmlutil.HTMLWriter, d string) error { + return renderImage(w, "jpeg", d) + }}, + {"image/svg+xml", func(w htmlutil.HTMLWriter, d string) error { + return renderImage(w, "svg+xml", base64.StdEncoding.EncodeToString(util.UnsafeStringToBytes(d))) + }}, + + // Rich & Math Layouts + {"text/html", func(w htmlutil.HTMLWriter, d string) error { + // To future developers: don't allow custom CSS classes or attributes, + // because ".link-action" or "data-fetch-xxx" can send POST requests and lead to XSS. + // If you'd really like to support more, do remember to correctly sanitize the values. + w.WriteFormat(`
%s
`, markup.Sanitize(d)) + return w.Err() + }}, + {"text/latex", func(w htmlutil.HTMLWriter, d string) error { + w.WriteFormat(`
%s
`, trimMathDelimiters(d)) + return w.Err() + }}, + {"text/plain", renderCellCodeOutputTextPlain}, + + // Security Placeholders + {"application/javascript", renderUnsupportedOutput("[JavaScript output - execution disabled for security]")}, + {"application/vnd.plotly.v1+json", renderUnsupportedOutput("[Plotly output - interactive plots not supported]")}, + {"application/vnd.jupyter.widget-view+json", renderUnsupportedOutput("[Jupyter widget - interactive widgets not supported]")}, + } +}) + +func (renderer) Name() string { + return "jupyter-render" +} + +func (renderer) NeedPostProcess() bool { return true } + +func (renderer) GetExternalRendererOptions() markup.ExternalRendererOptions { + return markup.ExternalRendererOptions{ + // HINT: no need to let markup render sanitize the output because there are many special CSS class names, inline attributes. + // This render must guarantee that the output is safe and no XSS + SanitizerDisabled: true, + } +} + +func (renderer) FileNamePatterns() []string { + return []string{"*.ipynb"} +} + +func (renderer) SanitizerRules() []setting.MarkupSanitizerRule { + return nil +} + +// Notebook structures +type Notebook struct { + Cells []Cell `json:"cells"` + Metadata map[string]any `json:"metadata"` + Nbformat int `json:"nbformat"` +} + +type Cell struct { + CellType string `json:"cell_type"` + Source any `json:"source"` // string or []string + Outputs []Output `json:"outputs,omitempty"` + ExecutionCount any `json:"execution_count,omitempty"` // int or null + Metadata map[string]any `json:"metadata,omitempty"` +} + +type Output struct { + OutputType string `json:"output_type"` + Data map[string]any `json:"data,omitempty"` + Text any `json:"text,omitempty"` // string or []string + Name string `json:"name,omitempty"` + Traceback any `json:"traceback,omitempty"` // []string + Ename string `json:"ename,omitempty"` + Evalue string `json:"evalue,omitempty"` +} + +// Render renders Jupyter notebook to HTML +func (renderer) Render(ctx *markup.RenderContext, input io.Reader, outputWriter io.Writer) error { + htmlWriter := htmlutil.NewHTMLWriter(outputWriter) + // the size is (should be) checked and/or limited by the caller to avoid OOM + var notebook Notebook + if err := json.NewDecoder(input).Decode(¬ebook); err != nil { + htmlWriter.WriteFormat(`
Failed to parse notebook JSON: %v
`, err) + return htmlWriter.Err() + } + + // Check nbformat version + if notebook.Nbformat < 4 { + htmlWriter.WriteFormat( + `
This notebook uses an older format (nbformat %d). Only nbformat 4+ is supported for rendering. Please upgrade the notebook in Jupyter or view the raw JSON.
`, + notebook.Nbformat, + ) + return htmlWriter.Err() + } + + // Detect language + language := "python" // default + if metadata, ok := notebook.Metadata["language_info"].(map[string]any); ok { + if name, ok := metadata["name"].(string); ok { + language = name + } + } else if kernelSpec, ok := notebook.Metadata["kernelspec"].(map[string]any); ok { + if lang, ok := kernelSpec["language"].(string); ok { + language = lang + } + } + + // Start rendering + htmlWriter.WriteHTML(`
`) + + // limiting the cell rendering to 100 cells + cells := notebook.Cells + truncated := false + const maxRenderedCells = 100 + + if len(cells) > maxRenderedCells { + cells = cells[:maxRenderedCells] // Slice down to exactly 100 elements instantly at the pointer layer + truncated = true + } + + for _, cell := range cells { + if err := renderCell(ctx, htmlWriter, cell, language); err != nil { + log.Warn("Failed to render cell: %v", err) // TODO: RENDER-LOG-HANDLING: see other comments + continue + } + } + + if truncated { + htmlWriter.WriteHTML(`
`) + htmlWriter.WriteHTML(`Output truncated. This notebook contains too many cells to display efficiently.`) + htmlWriter.WriteHTML(`
`) + } + + htmlWriter.WriteHTML(`
`) + return htmlWriter.Err() +} + +func renderCellCode(output htmlutil.HTMLWriter, cell Cell, language string) error { + source := joinSource(cell.Source) + var executionCount *int64 + if cell.ExecutionCount != nil { + if count, err := util.ToInt64(cell.ExecutionCount); err == nil { + executionCount = &count + } + } + + output.WriteHTML(`
`) + { + if executionCount != nil { + output.WriteFormat(`
In [%d]:
`, *executionCount) + } else { + output.WriteHTML(`
In [ ]:
`) + } + + // Highlight code + lexer := highlight.DetectChromaLexerByFileName("", language) + output.WriteFormat(`
`, strings.ToLower(language))
+		output.WriteHTML(highlight.RenderCodeByLexer(lexer, source))
+		output.WriteHTML("
") + } + output.WriteHTML(`
`) + + // Render outputs + if len(cell.Outputs) > 0 { + hasExecutionResult := false + for _, out := range cell.Outputs { + if out.OutputType == "execute_result" { + hasExecutionResult = true + break + } + } + + output.WriteHTML(`
`) + { + if hasExecutionResult && executionCount != nil { + output.WriteFormat(`
Out [%d]:
`, *executionCount) + } else { + output.WriteHTML(`
`) + } + + output.WriteHTML(`
`) + for _, out := range cell.Outputs { + renderCellCodeOutput(output, out) + } + output.WriteHTML(`
`) + } + output.WriteHTML(`
`) + } + + return output.Err() +} + +func renderCell(ctx *markup.RenderContext, output htmlutil.HTMLWriter, cell Cell, language string) error { + switch cell.CellType { + case "markdown": + output.WriteHTML(` +
+
+
+
`) + if err := renderCellMarkdown(ctx, output, joinSource(cell.Source)); err != nil { + return err + } + output.WriteHTML(`
`) + case "code": + output.WriteHTML(`
`) + if err := renderCellCode(output, cell, language); err != nil { + return err + } + output.WriteHTML(`
`) + default: + output.WriteFormat(` +
+
+
Cell:
+
[Cell type %s - unsupported, skipped]
+
+
`, cell.CellType) + } + return output.Err() +} + +func renderCellMarkdown(rctx *markup.RenderContext, output htmlutil.HTMLWriter, source string) error { + markdownCtx := markup.NewRenderContext(rctx) + // make sure the markdown render use the same options and helper to generate correct contents (e.g.: links) + markdownCtx.RenderOptions = rctx.RenderOptions + markdownCtx.RenderHelper = rctx.RenderHelper + output.WriteHTML(`
`) + if err := markdown.Render(markdownCtx, strings.NewReader(source), output.OriginWriter()); err != nil { + return err + } + output.WriteHTML(`
`) + return output.Err() +} + +func renderCellCodeOutput(output htmlutil.HTMLWriter, out Output) { + if out.Data != nil { + // Iterate through our priority list to find the best matching MIME handler available + for _, h := range dataMimeHandlers() { + if rawPayload, exists := out.Data[h.Mime]; exists { + var stringPayload string + + // Flatten the polymorphic JSON input (string or []any) into a single clean string + switch v := rawPayload.(type) { + case string: + stringPayload = v + case []any: + stringPayload = joinSource(v) + default: + _ = renderCellCodeOutputUnsupported(output, fmt.Sprintf("[Data output - unsupported data type %T for mime type %s]", rawPayload, h.Mime)) + continue + } + + if err := h.Fn(output, stringPayload); err != nil { + // TODO: RENDER-LOG-HANDLING: outputting render's error to sever's log is not a proper approach + // The errors can be: + // * unsupported element (cell, data, etc): it should render the message on the UI to tell users that the content is not supported, or ignore them if they are ignore-able + // * logic error: it should report to server logs + // * network error: io.Writer tries to write to the HTTP connection, so the error can also be a network error, such error should be ignored + log.Error("Jupyter rendering engine failed for MIME type %s: %v", h.Mime, err) + } + + // Return immediately after rendering the top matching priority format + return + } + } + } + + // Stream output + if out.OutputType == "stream" && out.Text != nil { + streamName := util.Iif(out.Name == "stderr", "stderr", "stdout") + output.WriteFormat(`
%s
`, streamName, joinSource(out.Text)) + return + } + + // Error output + if out.OutputType == "error" { + traceback := "" + if tb, ok := out.Traceback.([]any); ok { + lines := make([]string, len(tb)) + for i, line := range tb { + lines[i] = fmt.Sprint(line) + } + traceback = strings.Join(lines, "\n") + } + if traceback == "" && out.Ename != "" { + traceback = fmt.Sprintf("%s: %s", out.Ename, out.Evalue) + } + output.WriteFormat(`
%s
`, traceback) + return + } + + // Generic text output + if out.Text != nil { + _ = renderCellCodeOutputTextPlain(output, joinSource(out.Text)) + } +} + +func joinSource(source any) string { + switch v := source.(type) { + case nil: + return "" + case string: + return v + case []any: + // the "source slice item" has EOL ("\n"), so just join them together + parts := make([]string, len(v)) + for i, part := range v { + parts[i] = fmt.Sprint(part) + } + return strings.Join(parts, "") + default: + return fmt.Sprint(v) + } +} + +// trimMathDelimiters strips a single pair of surrounding math delimiters ("$$...$$" or "$...$"), +// so the inner expression is handled by the math post-processor. Unlike strings.Trim, it does not +// eat unrelated "$" characters elsewhere in multi-expression content. +func trimMathDelimiters(s string) string { + s = strings.TrimSpace(s) + if t, ok := strings.CutPrefix(s, "$$"); ok { + return strings.TrimSuffix(t, "$$") + } + if t, ok := strings.CutPrefix(s, "$"); ok { + return strings.TrimSuffix(t, "$") + } + return s +} diff --git a/modules/markup/jupyter/jupyter_test.go b/modules/markup/jupyter/jupyter_test.go new file mode 100644 index 00000000000..fd1464d1727 --- /dev/null +++ b/modules/markup/jupyter/jupyter_test.go @@ -0,0 +1,314 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package jupyter + +import ( + "fmt" + "strings" + "testing" + + "gitea.dev/modules/markup" + "gitea.dev/modules/test" + + "github.com/stretchr/testify/assert" +) + +func TestRender(t *testing.T) { + r := renderer{} + + t.Run("Basic notebook", func(t *testing.T) { + input := `{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "source": ["print('hello')"], + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": ["hello\n"] + } + ] + } + ], + "metadata": {}, + "nbformat": 4 + }` + + var output strings.Builder + ctx := &markup.RenderContext{} + err := r.Render(ctx, strings.NewReader(input), &output) + + assert.NoError(t, err) + result := output.String() + assert.Contains(t, result, `
`) + assert.Contains(t, result, `
`) + assert.Contains(t, result, `In [1]:`) + assert.Contains(t, result, `print`) + assert.Contains(t, result, `hello`) + assert.Contains(t, result, `stream-stdout`) + }) + + t.Run("Markdown cell with XSS Protection", func(t *testing.T) { + input := `{ + "cells": [ + { + "cell_type": "markdown", + "source": [ + "# Title\n", + "Some text\n", + "[click me](javascript:alert(1))\n", + "" + ] + } + ], + "metadata": {}, + "nbformat": 4 + }` + + var output strings.Builder + ctx := markup.NewRenderContext(t.Context()) + err := r.Render(ctx, strings.NewReader(input), &output) + + assert.NoError(t, err) + result := output.String() + + // Assert normal markup still renders correctly + assert.Contains(t, result, `
`) + assert.Contains(t, result, `Title`) + assert.Contains(t, result, `Some text`) + assert.Contains(t, result, `click me`) + + // CRITICAL SECURITY ASSERTIONS: Ensure XSS vectors are completely stripped + assert.NotContains(t, result, `javascript:alert`) + assert.NotContains(t, result, `
Safe Content
" + ] + }, + "metadata": {} + } + ] + } + ] + }` + + var output strings.Builder + ctx := markup.NewRenderContext(t.Context()) + ctx.RenderOptions.MarkupType = "jupyter-render" + err := markup.Render(ctx, strings.NewReader(maliciousNotebook), &output) + assert.NoError(t, err) + const expected = ` +
+
+
+
In [1]:
+
+

+					a=1
+				
+
+
+
+
Out [1]:
+
+
+
Safe Content
+
+
+
+
+
` + assert.Equal(t, test.NormalizeHTMLSpaces(expected), test.NormalizeHTMLSpaces(output.String())) +} diff --git a/modules/sitemap/sitemap.go b/modules/sitemap/sitemap.go index 280ca1d7100..5ac37033d92 100644 --- a/modules/sitemap/sitemap.go +++ b/modules/sitemap/sitemap.go @@ -63,20 +63,24 @@ func (s *Sitemap) Add(u URL) { // WriteTo writes the sitemap to a response func (s *Sitemap) WriteTo(w io.Writer) (int64, error) { if l := len(s.URLs); l > urlsLimit { - return 0, fmt.Errorf("The sitemap contains %d URLs, but only %d are allowed", l, urlsLimit) + return 0, fmt.Errorf("sitemap contains %d URLs, but only %d are allowed", l, urlsLimit) } if l := len(s.Sitemaps); l > urlsLimit { - return 0, fmt.Errorf("The sitemap contains %d sub-sitemaps, but only %d are allowed", l, urlsLimit) + return 0, fmt.Errorf("sitemap contains %d sub-sitemaps, but only %d are allowed", l, urlsLimit) } buf := bytes.NewBufferString(xml.Header) - if err := xml.NewEncoder(buf).Encode(s); err != nil { + encoder := xml.NewEncoder(buf) + defer encoder.Close() + if err := encoder.Encode(s); err != nil { return 0, err } + _ = encoder.Flush() if err := buf.WriteByte('\n'); err != nil { return 0, err } + // FIXME: such limit is not right, the content has been written, it would have already caused OOM if buf.Len() > sitemapFileLimit { - return 0, fmt.Errorf("The sitemap has %d bytes, but only %d are allowed", buf.Len(), sitemapFileLimit) + return 0, fmt.Errorf("sitemap has %d bytes, but only %d are allowed", buf.Len(), sitemapFileLimit) } return buf.WriteTo(w) } diff --git a/modules/sitemap/sitemap_test.go b/modules/sitemap/sitemap_test.go index 1180463cd79..9ff97939014 100644 --- a/modules/sitemap/sitemap_test.go +++ b/modules/sitemap/sitemap_test.go @@ -61,14 +61,14 @@ func TestNewSitemap(t *testing.T) { { name: "too many urls", urls: make([]URL, 50001), - wantErr: "The sitemap contains 50001 URLs, but only 50000 are allowed", + wantErr: "sitemap contains 50001 URLs, but only 50000 are allowed", }, { name: "too big file", urls: []URL{ {URL: strings.Repeat("b", 50*1024*1024+1)}, }, - wantErr: "The sitemap has 52428932 bytes, but only 52428800 are allowed", + wantErr: "sitemap has 52428932 bytes, but only 52428800 are allowed", }, } for _, tt := range tests { @@ -137,14 +137,14 @@ func TestNewSitemapIndex(t *testing.T) { { name: "too many sitemaps", urls: make([]URL, 50001), - wantErr: "The sitemap contains 50001 sub-sitemaps, but only 50000 are allowed", + wantErr: "sitemap contains 50001 sub-sitemaps, but only 50000 are allowed", }, { name: "too big file", urls: []URL{ {URL: strings.Repeat("b", 50*1024*1024+1)}, }, - wantErr: "The sitemap has 52428952 bytes, but only 52428800 are allowed", + wantErr: "sitemap has 52428952 bytes, but only 52428800 are allowed", }, } for _, tt := range tests { diff --git a/modules/structs/token.go b/modules/structs/token.go new file mode 100644 index 00000000000..af72aca487c --- /dev/null +++ b/modules/structs/token.go @@ -0,0 +1,31 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +import "time" + +// CurrentAccessToken represents the metadata of the currently authenticated token. +// swagger:model CurrentAccessToken +type CurrentAccessToken struct { + // The unique identifier of the access token + ID int64 `json:"id"` + // The name of the access token + Name string `json:"name"` + // The scopes granted to this access token + Scopes []string `json:"scopes"` + // The timestamp when the token was created + CreatedAt time.Time `json:"created_at"` + // The timestamp when the token was last used + LastUsedAt time.Time `json:"last_used_at"` + // The owner of the access token + User *UserMeta `json:"user"` +} + +// UserMeta represents minimal user information for the token owner. +type UserMeta struct { + // The unique identifier of the user + ID int64 `json:"id"` + // The username of the user + Login string `json:"login"` +} diff --git a/modules/test/utils.go b/modules/test/utils.go index 5a4e13b4232..12b35f42f7a 100644 --- a/modules/test/utils.go +++ b/modules/test/utils.go @@ -12,12 +12,16 @@ import ( "net/http" "net/http/httptest" "os" + "regexp" + "slices" "strconv" "strings" "sync" "gitea.dev/modules/json" "gitea.dev/modules/util" + + "golang.org/x/net/html" ) // RedirectURL returns the redirect URL of a http response. @@ -182,3 +186,48 @@ func ExternalServiceHTTP(t TestingT, envVarName, def string) string { } return val } + +var normalizeHTMLSpacesRegexp = sync.OnceValue(func() (ret struct { + afterRt, beforeLt *regexp.Regexp +}, +) { + ret.afterRt = regexp.MustCompile(`>\s*`) + ret.beforeLt = regexp.MustCompile(`\s*<`) + return ret +}) + +func NormalizeHTMLSpaces(s string) string { + vars := normalizeHTMLSpacesRegexp() + s = vars.afterRt.ReplaceAllString(s, ">\n") + s = vars.beforeLt.ReplaceAllString(s, "\n<") + return strings.TrimSpace(s) +} + +func NormalizeHTMLAttributes(t TestingT, s string) string { + nodes, err := html.Parse(strings.NewReader(s)) + if err != nil { + t.Errorf("failed to parse expected HTML: %v", err) + return "" + } + + var normalize func(n *html.Node) + normalize = func(n *html.Node) { + slices.SortFunc(n.Attr, func(a, b html.Attribute) int { + if cmp := strings.Compare(a.Namespace, b.Namespace); cmp != 0 { + return cmp + } + if cmp := strings.Compare(a.Key, b.Key); cmp != 0 { + return cmp + } + return strings.Compare(a.Val, b.Val) + }) + for c := n.FirstChild; c != nil; c = c.NextSibling { + normalize(c) + } + } + var sb strings.Builder + if err = html.Render(&sb, nodes); err != nil { + t.Errorf("failed to render HTML: %v", err) + } + return sb.String() +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 3bac1eac919..4715ca1d672 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -88,6 +88,7 @@ import ( "gitea.dev/routers/api/v1/packages" "gitea.dev/routers/api/v1/repo" "gitea.dev/routers/api/v1/settings" + "gitea.dev/routers/api/v1/token" "gitea.dev/routers/api/v1/user" "gitea.dev/routers/common" "gitea.dev/services/actions" @@ -976,6 +977,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. diff --git a/routers/api/v1/repo/issue_tracked_time.go b/routers/api/v1/repo/issue_tracked_time.go index 33af841fbd1..ff723e679ff 100644 --- a/routers/api/v1/repo/issue_tracked_time.go +++ b/routers/api/v1/repo/issue_tracked_time.go @@ -91,6 +91,7 @@ func ListTrackedTimes(ctx *context.APIContext) { user, err := user_model.GetUserByName(ctx, qUser) if user_model.IsErrUserNotExist(err) { ctx.APIError(http.StatusNotFound, err.Error()) + return } else if err != nil { ctx.APIErrorInternal(err) return @@ -499,6 +500,7 @@ func ListTrackedTimesByRepository(ctx *context.APIContext) { user, err := user_model.GetUserByName(ctx, qUser) if user_model.IsErrUserNotExist(err) { ctx.APIError(http.StatusNotFound, err.Error()) + return } else if err != nil { ctx.APIErrorInternal(err) return diff --git a/routers/api/v1/swagger/app.go b/routers/api/v1/swagger/app.go index dc30cda6996..3097035e456 100644 --- a/routers/api/v1/swagger/app.go +++ b/routers/api/v1/swagger/app.go @@ -20,3 +20,10 @@ type swaggerResponseAccessToken struct { // in:body Body api.AccessToken `json:"body"` } + +// CurrentAccessToken represents the currently authenticated access token. +// swagger:response CurrentAccessToken +type swaggerResponseCurrentAccessToken struct { + // in:body + Body api.CurrentAccessToken `json:"body"` +} diff --git a/routers/api/v1/token/token.go b/routers/api/v1/token/token.go new file mode 100644 index 00000000000..7712a7c8c2e --- /dev/null +++ b/routers/api/v1/token/token.go @@ -0,0 +1,88 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package token + +import ( + "errors" + "net/http" + + auth_model "gitea.dev/models/auth" + user_model "gitea.dev/models/user" + "gitea.dev/modules/auth/httpauth" + api "gitea.dev/modules/structs" + "gitea.dev/modules/util" + "gitea.dev/services/context" +) + +// GetCurrentToken returns metadata about the currently authenticated token. +func GetCurrentToken(ctx *context.APIContext) { + // swagger:operation GET /token miscellaneous getCurrentToken + // --- + // summary: Get the currently authenticated token + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/CurrentAccessToken" + accessToken, err := getToken(ctx) + if err != nil { + ctx.APIErrorAuto(err) + return + } + + // Get user info + user, err := user_model.GetUserByID(ctx, accessToken.UID) + if err != nil { + ctx.APIErrorAuto(err) + return + } + + ctx.JSON(http.StatusOK, &api.CurrentAccessToken{ + ID: accessToken.ID, + Name: accessToken.Name, + Scopes: accessToken.Scope.StringSlice(), + CreatedAt: accessToken.CreatedUnix.AsTime(), + LastUsedAt: accessToken.UpdatedUnix.AsTime(), + User: &api.UserMeta{ + ID: user.ID, + Login: user.Name, + }, + }) +} + +// DeleteCurrentToken deletes the currently authenticated token. +func DeleteCurrentToken(ctx *context.APIContext) { + // swagger:operation DELETE /token miscellaneous deleteCurrentToken + // --- + // summary: Delete the currently authenticated token + // produces: + // - application/json + // responses: + // "204": + // description: token deleted + accessToken, err := getToken(ctx) + if err != nil { + ctx.APIErrorAuto(err) + return + } + + // Delete the token + err = auth_model.DeleteAccessTokenByID(ctx, accessToken.ID, accessToken.UID) + if err != nil && !errors.Is(err, util.ErrNotExist) { + ctx.APIErrorAuto(err) + return + } + ctx.Status(http.StatusNoContent) +} + +// getToken retrieves an access token from the API context's Authorization header and validates it against the database. +// Returns nil if the token is invalid and handles the response +func getToken(ctx *context.APIContext) (*auth_model.AccessToken, error) { + authHeader := ctx.Req.Header.Get("Authorization") + parsed, ok := httpauth.ParseAuthorizationHeader(authHeader) + if !ok || parsed.BearerToken == nil { + return nil, util.NewNotExistErrorf("invalid access token") + } + return auth_model.GetAccessTokenBySHA(ctx, parsed.BearerToken.Token) +} diff --git a/routers/api/v1/user/app.go b/routers/api/v1/user/app.go index a410909e0e5..87aef1d10de 100644 --- a/routers/api/v1/user/app.go +++ b/routers/api/v1/user/app.go @@ -191,17 +191,9 @@ func DeleteAccessToken(ctx *context.APIContext) { return } } - if tokenID == 0 { - ctx.APIErrorInternal(nil) - return - } if err := auth_model.DeleteAccessTokenByID(ctx, tokenID, ctx.ContextUser.ID); err != nil { - if auth_model.IsErrAccessTokenNotExist(err) { - ctx.APIErrorNotFound() - } else { - ctx.APIErrorInternal(err) - } + ctx.APIErrorAuto(err) return } diff --git a/routers/web/admin/orgs.go b/routers/web/admin/orgs.go index 1474038c915..02037af89fa 100644 --- a/routers/web/admin/orgs.go +++ b/routers/web/admin/orgs.go @@ -23,10 +23,7 @@ func Organizations(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("admin.organizations") ctx.Data["PageIsAdminOrganizations"] = true - if ctx.FormString("sort") == "" { - ctx.SetFormString("sort", UserSearchDefaultAdminSort) - } - + sortOrder := ctx.FormString("sort", UserSearchDefaultAdminSort) explore.RenderUserSearch(ctx, user_model.SearchUserOptions{ Actor: ctx.Doer, Types: []user_model.UserType{user_model.UserTypeOrganization}, @@ -35,5 +32,6 @@ func Organizations(ctx *context.Context) { PageSize: setting.UI.Admin.OrgPagingNum, }, Visible: []structs.VisibleType{structs.VisibleTypePublic, structs.VisibleTypeLimited, structs.VisibleTypePrivate}, + OrderBy: db.SearchOrderBy(sortOrder), }, tplOrgs) } diff --git a/routers/web/admin/users.go b/routers/web/admin/users.go index 4b345945089..f918c8b5d31 100644 --- a/routers/web/admin/users.go +++ b/routers/web/admin/users.go @@ -55,11 +55,7 @@ func Users(ctx *context.Context) { statusFilterMap[filterKey] = paramVal } - sortType := ctx.FormString("sort") - if sortType == "" { - sortType = UserSearchDefaultAdminSort - ctx.SetFormString("sort", sortType) - } + sortType := ctx.FormString("sort", UserSearchDefaultAdminSort) ctx.PageData["adminUserListSearchForm"] = map[string]any{ "StatusFilterMap": statusFilterMap, "SortType": sortType, @@ -78,6 +74,7 @@ func Users(ctx *context.Context) { IsTwoFactorEnabled: optional.ParseBool(statusFilterMap["is_2fa_enabled"]), IsProhibitLogin: optional.ParseBool(statusFilterMap["is_prohibit_login"]), IncludeReserved: true, // administrator needs to list all accounts include reserved, bot, remote ones + OrderBy: db.SearchOrderBy(sortType), }, tplUsers) } diff --git a/routers/web/explore/org.go b/routers/web/explore/org.go index 621f6bd97a5..687d83ff36c 100644 --- a/routers/web/explore/org.go +++ b/routers/web/explore/org.go @@ -38,17 +38,14 @@ func Organizations(ctx *context.Context) { "alphabetically", "reversealphabetically", ) - sortOrder := ctx.FormString("sort") - if sortOrder == "" { - sortOrder = util.Iif(supportedSortOrders.Contains(setting.UI.ExploreDefaultSort), setting.UI.ExploreDefaultSort, "newest") - ctx.SetFormString("sort", sortOrder) - } - + sortOrderDefault := util.Iif(supportedSortOrders.Contains(setting.UI.ExploreDefaultSort), setting.UI.ExploreDefaultSort, "newest") + sortOrder := ctx.FormString("sort", sortOrderDefault) RenderUserSearch(ctx, user_model.SearchUserOptions{ Actor: ctx.Doer, Types: []user_model.UserType{user_model.UserTypeOrganization}, ListOptions: db.ListOptions{PageSize: setting.UI.ExplorePagingNum}, Visible: visibleTypes, + OrderBy: db.SearchOrderBy(sortOrder), SupportedSortOrders: supportedSortOrders, }, tplExploreUsers) diff --git a/routers/web/explore/user.go b/routers/web/explore/user.go index 00217d662c1..64e2a92d685 100644 --- a/routers/web/explore/user.go +++ b/routers/web/explore/user.go @@ -55,11 +55,7 @@ func RenderUserSearch(ctx *context.Context, opts user_model.SearchUserOptions, t ) // we can not set orderBy to `models.SearchOrderByXxx`, because there may be a JOIN in the statement, different tables may have the same name columns - - sortOrder := ctx.FormString("sort") - if sortOrder == "" { - sortOrder = setting.UI.ExploreDefaultSort - } + sortOrder := util.IfZero(string(opts.OrderBy), ctx.FormString("sort", setting.UI.ExploreDefaultSort)) ctx.Data["SortType"] = sortOrder switch sortOrder { @@ -145,18 +141,15 @@ func Users(ctx *context.Context) { "alphabetically", "reversealphabetically", ) - sortOrder := ctx.FormString("sort") - if sortOrder == "" { - sortOrder = util.Iif(supportedSortOrders.Contains(setting.UI.ExploreDefaultSort), setting.UI.ExploreDefaultSort, "newest") - ctx.SetFormString("sort", sortOrder) - } - + sortOrderDefault := util.Iif(supportedSortOrders.Contains(setting.UI.ExploreDefaultSort), setting.UI.ExploreDefaultSort, "newest") + sortOrder := ctx.FormString("sort", sortOrderDefault) RenderUserSearch(ctx, user_model.SearchUserOptions{ Actor: ctx.Doer, Types: []user_model.UserType{user_model.UserTypeIndividual}, ListOptions: db.ListOptions{PageSize: setting.UI.ExplorePagingNum}, IsActive: optional.Some(true), Visible: []structs.VisibleType{structs.VisibleTypePublic, structs.VisibleTypeLimited, structs.VisibleTypePrivate}, + OrderBy: db.SearchOrderBy(sortOrder), SupportedSortOrders: supportedSortOrders, }, tplExploreUsers) diff --git a/routers/web/user/setting/security/openid_test.go b/routers/web/user/setting/security/openid_test.go index 046e7357e1b..f00506effaf 100644 --- a/routers/web/user/setting/security/openid_test.go +++ b/routers/web/user/setting/security/openid_test.go @@ -15,22 +15,16 @@ import ( func TestDeleteOpenIDReturnsNotFoundForOtherUsersAddress(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := contexttest.MockContext(t, "POST /user/settings/security") + ctx, _ := contexttest.MockContext(t, "POST /user/settings/security?id=1") contexttest.LoadUser(t, ctx, 2) - ctx.SetFormString("id", "1") - DeleteOpenID(ctx) - assert.Equal(t, http.StatusNotFound, ctx.Resp.WrittenStatus()) } func TestToggleOpenIDVisibilityReturnsNotFoundForOtherUsersAddress(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := contexttest.MockContext(t, "POST /user/settings/security") + ctx, _ := contexttest.MockContext(t, "POST /user/settings/security?id=1") contexttest.LoadUser(t, ctx, 2) - ctx.SetFormString("id", "1") - ToggleOpenIDVisibility(ctx) - assert.Equal(t, http.StatusNotFound, ctx.Resp.WrittenStatus()) } diff --git a/services/auth/basic.go b/services/auth/basic.go index c7db14e6e7e..ed2a2e1945c 100644 --- a/services/auth/basic.go +++ b/services/auth/basic.go @@ -5,6 +5,7 @@ package auth import ( + "errors" "net/http" actions_model "gitea.dev/models/actions" @@ -104,8 +105,8 @@ func (b *Basic) VerifyAuthToken(req *http.Request, w http.ResponseWriter, store store.GetData()["IsApiToken"] = true store.GetData()["ApiTokenScope"] = token.Scope return u, nil - } else if !auth_model.IsErrAccessTokenNotExist(err) && !auth_model.IsErrAccessTokenEmpty(err) { - log.Error("GetAccessTokenBySha: %v", err) + } else if !errors.Is(err, util.ErrNotExist) { + log.Error("GetAccessTokenBySHA: %v", err) } // check task token diff --git a/services/auth/oauth2.go b/services/auth/oauth2.go index a2f7d5d1e7f..cb622c22581 100644 --- a/services/auth/oauth2.go +++ b/services/auth/oauth2.go @@ -128,7 +128,7 @@ func (o *OAuth2) userFromToken(ctx context.Context, tokenSHA string, store DataS } t, err := auth_model.GetAccessTokenBySHA(ctx, tokenSHA) if err != nil { - if auth_model.IsErrAccessTokenNotExist(err) { + if errors.Is(err, util.ErrNotExist) { // check task token if task, err := actions_model.GetRunningTaskByToken(ctx, tokenSHA); err == nil { log.Trace("Basic Authorization: Valid AccessToken for task[%d]", task.ID) diff --git a/services/context/base_form.go b/services/context/base_form.go index 088888b461e..c6a70991297 100644 --- a/services/context/base_form.go +++ b/services/context/base_form.go @@ -78,8 +78,3 @@ func (b *Base) FormOptionalBool(key string) optional.Option[bool] { v = v || strings.EqualFold(s, "on") return optional.Some(v) } - -func (b *Base) SetFormString(key, value string) { - _ = b.Req.FormValue(key) // force parse form - b.Req.Form.Set(key, value) -} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 286bec3a50f..861cb88c2a4 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -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": [ @@ -25116,6 +25148,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", @@ -30585,6 +30658,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 +31180,12 @@ } } }, + "CurrentAccessToken": { + "description": "CurrentAccessToken represents the currently authenticated access token.", + "schema": { + "$ref": "#/definitions/CurrentAccessToken" + } + }, "DeployKey": { "description": "DeployKey", "schema": { diff --git a/templates/swagger/v1_openapi3_json.tmpl b/templates/swagger/v1_openapi3_json.tmpl index 6fd253528e9..782e9bce426 100644 --- a/templates/swagger/v1_openapi3_json.tmpl +++ b/templates/swagger/v1_openapi3_json.tmpl @@ -399,6 +399,16 @@ }, "description": "CronList" }, + "CurrentAccessToken": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CurrentAccessToken" + } + } + }, + "description": "CurrentAccessToken represents the currently authenticated access token." + }, "DeployKey": { "content": { "application/json": { @@ -4952,6 +4962,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": { @@ -10454,6 +10505,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 +31454,32 @@ ] } }, + "/token": { + "delete": { + "operationId": "deleteCurrentToken", + "responses": { + "204": { + "description": "token deleted" + } + }, + "summary": "Delete the currently authenticated token", + "tags": [ + "miscellaneous" + ] + }, + "get": { + "operationId": "getCurrentToken", + "responses": { + "200": { + "$ref": "#/components/responses/CurrentAccessToken" + } + }, + "summary": "Get the currently authenticated token", + "tags": [ + "miscellaneous" + ] + } + }, "/topics/search": { "get": { "operationId": "topicSearch", diff --git a/tests/integration/api_issue_tracked_time_test.go b/tests/integration/api_issue_tracked_time_test.go index fcbe4dfa519..0ae8a858ac1 100644 --- a/tests/integration/api_issue_tracked_time_test.go +++ b/tests/integration/api_issue_tracked_time_test.go @@ -13,6 +13,7 @@ import ( issues_model "gitea.dev/models/issues" "gitea.dev/models/unittest" user_model "gitea.dev/models/user" + "gitea.dev/modules/json" api "gitea.dev/modules/structs" "gitea.dev/tests" @@ -61,6 +62,44 @@ func TestAPIGetTrackedTimes(t *testing.T) { assert.Equal(t, int64(6), filterAPITimes[1].ID) } +// TestAPIGetTrackedTimesNonExistentUserFilter ensures filtering by a user that +// does not exist returns a clean 404 instead of panicking (nil pointer dereference). +func TestAPIGetTrackedTimesNonExistentUserFilter(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + issue2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2}) + assert.NoError(t, issue2.LoadRepo(t.Context())) + + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue, auth_model.AccessTokenScopeReadRepository) + + for _, tc := range []struct { + name string + url string + }{ + {"repository level", fmt.Sprintf("/api/v1/repos/%s/%s/times?user=nonexistentuser", user2.Name, issue2.Repo.Name)}, + {"issue level", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/times?user=nonexistentuser", user2.Name, issue2.Repo.Name, issue2.Index)}, + } { + t.Run(tc.name, func(t *testing.T) { + req := NewRequest(t, "GET", tc.url).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusNotFound) + + assert.True(t, json.Valid(resp.Body.Bytes()), "response body must be a single JSON value, got: %s", resp.Body.Bytes()) + + var apiError api.APIError + DecodeJSON(t, resp, &apiError) + assert.Contains(t, apiError.Message, "user does not exist") + }) + } + + t.Run("existing user", func(t *testing.T) { + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/times?user=%s", user2.Name, issue2.Repo.Name, user2.Name).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, api.TrackedTimeList{}) + }) +} + func TestAPIDeleteTrackedTime(t *testing.T) { defer tests.PrepareTestEnv(t)() diff --git a/tests/integration/api_token_self_test.go b/tests/integration/api_token_self_test.go new file mode 100644 index 00000000000..2720c59d2c5 --- /dev/null +++ b/tests/integration/api_token_self_test.go @@ -0,0 +1,98 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + auth_model "gitea.dev/models/auth" + "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" + api "gitea.dev/modules/structs" + "gitea.dev/tests" + + "github.com/stretchr/testify/assert" +) + +// TestAPIGetCurrentToken tests getting metadata of the currently authenticated token +func TestAPIGetCurrentToken(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + t.Run("Success with all scopes", func(t *testing.T) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + accessToken := createAPIAccessTokenWithoutCleanUp(t, "test-get-current-token-all", user, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll}) + + req := NewRequest(t, "GET", "/api/v1/token"). + AddTokenAuth(accessToken.Token) + resp := MakeRequest(t, req, http.StatusOK) + + currentToken := DecodeJSON(t, resp, &api.CurrentAccessToken{}) + assert.Equal(t, accessToken.ID, currentToken.ID) + assert.Equal(t, accessToken.Name, currentToken.Name) + assert.Equal(t, user.ID, currentToken.User.ID) + assert.Equal(t, user.Name, currentToken.User.Login) + }) + + t.Run("Success with limited scopes", func(t *testing.T) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + accessToken := createAPIAccessTokenWithoutCleanUp(t, "test-get-current-token-limited", user, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeReadRepository}) + + req := NewRequest(t, "GET", "/api/v1/token"). + AddTokenAuth(accessToken.Token) + resp := MakeRequest(t, req, http.StatusOK) + + currentToken := DecodeJSON(t, resp, &api.CurrentAccessToken{}) + assert.Equal(t, accessToken.ID, currentToken.ID) + assert.Equal(t, accessToken.Name, currentToken.Name) + assert.Equal(t, user.ID, currentToken.User.ID) + assert.Equal(t, user.Name, currentToken.User.Login) + }) + + t.Run("Bad token", func(t *testing.T) { + req := NewRequest(t, "GET", "/api/v1/token"). + AddTokenAuth("this does not exist") + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "GET", "/api/v1/token") + MakeRequest(t, req, http.StatusUnauthorized) + }) +} + +// TestAPITokenSelfService tests delete operations on token +func TestAPITokenSelfService(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + t.Run("Success then verify deleted", func(t *testing.T) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + accessToken := createAPIAccessTokenWithoutCleanUp(t, "test-delete-current-token", user, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll}) + + // Delete the token via the endpoint + req := NewRequest(t, "DELETE", "/api/v1/token"). + AddTokenAuth(accessToken.Token) + MakeRequest(t, req, http.StatusNoContent) + + // Verify the token is deleted + unittest.AssertNotExistsBean(t, &auth_model.AccessToken{ID: accessToken.ID}) + + // Verify the token can no longer be used for GET + req = NewRequest(t, "GET", "/api/v1/token"). + AddTokenAuth(accessToken.Token) + MakeRequest(t, req, http.StatusUnauthorized) + + // Verify the token can no longer be used for DELETE + req = NewRequest(t, "DELETE", "/api/v1/token"). + AddTokenAuth(accessToken.Token) + MakeRequest(t, req, http.StatusUnauthorized) + }) + + t.Run("Bad token", func(t *testing.T) { + req := NewRequest(t, "DELETE", "/api/v1/token"). + AddTokenAuth("this does not exist") + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "DELETE", "/api/v1/token") + MakeRequest(t, req, http.StatusUnauthorized) + }) +} diff --git a/tests/integration/html_helper.go b/tests/integration/html_helper.go index cefe5592c4a..fc900e5a3de 100644 --- a/tests/integration/html_helper.go +++ b/tests/integration/html_helper.go @@ -5,13 +5,12 @@ package integration import ( "io" - "slices" - "strings" "testing" + "gitea.dev/modules/test" + "github.com/PuerkitoBio/goquery" "github.com/stretchr/testify/assert" - "golang.org/x/net/html" ) // HTMLDoc struct @@ -53,36 +52,10 @@ func AssertHTMLElement[T int | bool](t testing.TB, doc *HTMLDoc, selector string func assertHTMLEq(t testing.TB, expected, actual string) { t.Helper() - if expected == actual { + if expected == actual { // fast path return } - exp, err := html.Parse(strings.NewReader(expected)) - if !assert.NoError(t, err) { - return - } - act, err := html.Parse(strings.NewReader(actual)) - if !assert.NoError(t, err) { - return - } - var normalize func(n *html.Node) - normalize = func(n *html.Node) { - slices.SortFunc(n.Attr, func(a, b html.Attribute) int { - if cmp := strings.Compare(a.Namespace, b.Namespace); cmp != 0 { - return cmp - } - if cmp := strings.Compare(a.Key, b.Key); cmp != 0 { - return cmp - } - return strings.Compare(a.Val, b.Val) - }) - for c := n.FirstChild; c != nil; c = c.NextSibling { - normalize(c) - } - } - normalize(exp) - normalize(act) - var expNormalized, actNormalized strings.Builder - assert.NoError(t, html.Render(&expNormalized, exp)) - assert.NoError(t, html.Render(&actNormalized, act)) - assert.Equal(t, expNormalized.String(), actNormalized.String()) + exp := test.NormalizeHTMLAttributes(t, expected) + act := test.NormalizeHTMLAttributes(t, actual) + assert.Equal(t, exp, act) } diff --git a/web_src/css/index.css b/web_src/css/index.css index 2d3e118825d..71e58e7c8ec 100644 --- a/web_src/css/index.css +++ b/web_src/css/index.css @@ -52,6 +52,7 @@ @import "./markup/content.css"; @import "./markup/codeblock.css"; @import "./markup/codepreview.css"; +@import "./markup/jupyter.css"; @import "./font_i18n.css"; @import "./base.css"; diff --git a/web_src/css/markup/jupyter.css b/web_src/css/markup/jupyter.css new file mode 100644 index 00000000000..ab128e64a77 --- /dev/null +++ b/web_src/css/markup/jupyter.css @@ -0,0 +1,93 @@ +.markup.jupyter-render { + padding: 0; +} + +.markup .jupyter-notebook { + padding: 20px; + background: var(--color-body); + border-bottom-left-radius: var(--border-radius); + border-bottom-right-radius: var(--border-radius); + font-family: var(--fonts-monospace); + display: flex; + flex-direction: column; + gap: 2em; +} + +/* cell code */ +.markup .jupyter-notebook .cell-line { + display: flex; + width: 100%; + gap: 0.5em; +} + +.markup .jupyter-notebook .cell-left { + width: 100px; + flex-shrink: 0; +} + +.markup .jupyter-notebook .cell-right { + flex: 1; +} + +.markup .jupyter-notebook .cell-prompt { + padding: 10px 0; + color: var(--color-text-light-2); + font-size: 13px; +} + +.markup .jupyter-notebook .cell-left.cell-prompt { + padding-left: 10px; + text-align: right; + white-space: nowrap; + user-select: none; +} + +.markup .jupyter-notebook .cell-right.cell-prompt { + padding-right: 10px; +} + +.markup .jupyter-notebook .cell-input, +.markup .jupyter-notebook .cell-output { + overflow-x: auto; +} + +.markup .jupyter-notebook .cell-input pre, +.markup .jupyter-notebook .cell-output pre { + padding: 10px 16px; + font-size: 13px; + min-height: 40px; + margin: 0; +} + +.markup .jupyter-notebook .cell-input pre { + background-color: var(--color-code-bg); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.markup .jupyter-notebook .cell-output { + display: flex; + flex-direction: column; + gap: 1em; +} + +.markup .jupyter-notebook .cell-type-code { + display: flex; + flex-direction: column; + gap: 1em; +} + +.markup .jupyter-notebook .cell-output-unsupported { + color: var(--color-text-light-2); + font-style: italic; + font-size: 13px; +} + +.markup .jupyter-notebook .cell-output-error { + color: var(--color-red); +} + +/* cell markdown */ +.markup .jupyter-notebook .cell-right .embedded-markdown { + padding: 0 16px; /* match cell code right padding */ +}