From 5f443184f317f683e9a2ea9e2af3b21aa1b2c314 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 5 Apr 2026 18:25:27 +0000 Subject: [PATCH 1/4] Update Nix flake (#37110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated changes by the [update-flake-lock](https://github.com/DeterminateSystems/update-flake-lock) GitHub Action. ``` Flake lock file updates: • Updated input 'nixpkgs': 'github:nixos/nixpkgs/46db2e0' (2026-03-24) → 'github:nixos/nixpkgs/6201e20' (2026-04-01) ``` Co-authored-by: github-actions[bot] --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 246cfd4e797..25ce7939b35 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1774386573, - "narHash": "sha256-4hAV26quOxdC6iyG7kYaZcM3VOskcPUrdCQd/nx8obc=", + "lastModified": 1775036866, + "narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=", "owner": "nixos", "repo": "nixpkgs", - "rev": "46db2e09e1d3f113a13c0d7b81e2f221c63b8ce9", + "rev": "6201e203d09599479a3b3450ed24fa81537ebc4e", "type": "github" }, "original": { From a8938115d4559fb1af34a95342ccc45df202c49a Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 5 Apr 2026 21:13:34 +0200 Subject: [PATCH 2/4] Merge some standalone Vite entries into index.js (#37085) Keep `swagger` and `external-render-helper` as a standalone entries for external render. - Move `devtest.ts` to `modules/` as init functions - Make external renders correctly load its helper JS and Gitea's current theme - Make external render iframe inherit Gitea's iframe's background color to avoid flicker - Add e2e tests for external render and OpenAPI iframe --------- Co-authored-by: Claude (Opus 4.6) Co-authored-by: wxiaoguang --- modules/markup/external/openapi.go | 10 ++- modules/markup/render.go | 37 ++++++---- modules/markup/render_test.go | 31 ++++++++ modules/public/manifest.go | 34 +++++---- modules/public/manifest_test.go | 11 --- modules/public/public.go | 11 +++ modules/public/vitedev.go | 51 +++++++------ routers/web/misc/swagger.go | 8 +- routers/web/repo/render.go | 4 +- routers/web/web.go | 2 +- services/webtheme/webtheme.go | 5 ++ stylelint.config.js | 2 +- tailwind.config.ts | 5 -- templates/base/head_style.tmpl | 2 +- templates/devtest/devtest-footer.tmpl | 2 - templates/devtest/devtest-header.tmpl | 6 +- .../swagger/{ui.tmpl => openapi-viewer.tmpl} | 8 +- tests/e2e/external-render.test.ts | 45 +++++++++++ tests/e2e/utils.ts | 7 ++ tests/integration/markup_external_test.go | 4 +- tools/test-e2e.sh | 7 ++ vite.config.ts | 74 +++++++++++-------- web_src/css/{standalone => }/devtest.css | 0 web_src/css/markup/content.css | 1 + .../css/standalone/external-render-iframe.css | 1 - web_src/css/standalone/swagger.css | 46 ------------ web_src/css/swagger.css | 41 ++++++++++ .../eventsource.sharedworker.ts | 0 ...er-iframe.ts => external-render-helper.ts} | 39 +++++++--- web_src/js/index.ts | 3 + web_src/js/markup/render-iframe.ts | 13 ++++ web_src/js/modules/devtest.ts | 20 +++++ web_src/js/standalone/devtest.ts | 18 ----- web_src/js/standalone/swagger.ts | 48 ------------ web_src/js/swagger.ts | 70 ++++++++++++++++++ 35 files changed, 419 insertions(+), 247 deletions(-) create mode 100644 modules/markup/render_test.go rename templates/swagger/{ui.tmpl => openapi-viewer.tmpl} (51%) create mode 100644 tests/e2e/external-render.test.ts rename web_src/css/{standalone => }/devtest.css (100%) delete mode 100644 web_src/css/standalone/external-render-iframe.css delete mode 100644 web_src/css/standalone/swagger.css create mode 100644 web_src/css/swagger.css rename web_src/js/{features => }/eventsource.sharedworker.ts (100%) rename web_src/js/{standalone/external-render-iframe.ts => external-render-helper.ts} (61%) create mode 100644 web_src/js/modules/devtest.ts delete mode 100644 web_src/js/standalone/devtest.ts delete mode 100644 web_src/js/standalone/swagger.ts create mode 100644 web_src/js/swagger.ts diff --git a/modules/markup/external/openapi.go b/modules/markup/external/openapi.go index de06e7dac70..91230e54d02 100644 --- a/modules/markup/external/openapi.go +++ b/modules/markup/external/openapi.go @@ -47,16 +47,22 @@ func (p *openAPIRenderer) SanitizerRules() []setting.MarkupSanitizerRule { func (p *openAPIRenderer) GetExternalRendererOptions() (ret markup.ExternalRendererOptions) { ret.SanitizerDisabled = true ret.DisplayInIframe = true - ret.ContentSandbox = "" + ret.ContentSandbox = "allow-scripts allow-forms allow-modals allow-popups allow-downloads" return ret } func (p *openAPIRenderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error { + if ctx.RenderOptions.StandalonePageOptions == nil { + opts := p.GetExternalRendererOptions() + return markup.RenderIFrame(ctx, &opts, output) + } + content, err := util.ReadWithLimit(input, int(setting.UI.MaxDisplayFileSize)) if err != nil { return err } - // TODO: can extract this to a tmpl file later + + // HINT: SWAGGER-OPENAPI-VIEWER: another place "templates/swagger/openapi-viewer.tmpl" _, err = io.WriteString(output, fmt.Sprintf( ` diff --git a/modules/markup/render.go b/modules/markup/render.go index c0d44c72fcc..caed3428e0b 100644 --- a/modules/markup/render.go +++ b/modules/markup/render.go @@ -38,6 +38,14 @@ var RenderBehaviorForTesting struct { DisableAdditionalAttributes bool } +type WebThemeInterface interface { + PublicAssetURI() string +} + +type StandalonePageOptions struct { + CurrentWebTheme WebThemeInterface +} + type RenderOptions struct { UseAbsoluteLink bool @@ -55,7 +63,7 @@ type RenderOptions struct { Metas map[string]string // used by external render. the router "/org/repo/render/..." will output the rendered content in a standalone page - InStandalonePage bool + StandalonePageOptions *StandalonePageOptions // EnableHeadingIDGeneration controls whether to auto-generate IDs for HTML headings without id attribute. // This should be enabled for repository files and wiki pages, but disabled for comments to avoid duplicate IDs. @@ -127,8 +135,8 @@ func (ctx *RenderContext) WithMetas(metas map[string]string) *RenderContext { return ctx } -func (ctx *RenderContext) WithInStandalonePage(v bool) *RenderContext { - ctx.RenderOptions.InStandalonePage = v +func (ctx *RenderContext) WithStandalonePage(opts StandalonePageOptions) *RenderContext { + ctx.RenderOptions.StandalonePageOptions = &opts return ctx } @@ -197,20 +205,18 @@ func RenderString(ctx *RenderContext, content string) (string, error) { return buf.String(), nil } -func renderIFrame(ctx *RenderContext, sandbox string, output io.Writer) error { +func RenderIFrame(ctx *RenderContext, opts *ExternalRendererOptions, output io.Writer) error { src := fmt.Sprintf("%s/%s/%s/render/%s/%s", setting.AppSubURL, url.PathEscape(ctx.RenderOptions.Metas["user"]), url.PathEscape(ctx.RenderOptions.Metas["repo"]), util.PathEscapeSegments(ctx.RenderOptions.Metas["RefTypeNameSubURL"]), util.PathEscapeSegments(ctx.RenderOptions.RelativePath), ) - - var sandboxAttrValue template.HTML - if sandbox != "" { - sandboxAttrValue = htmlutil.HTMLFormat(`sandbox="%s"`, sandbox) + var extraAttrs template.HTML + if opts.ContentSandbox != "" { + extraAttrs = htmlutil.HTMLFormat(` sandbox="%s"`, opts.ContentSandbox) } - iframe := htmlutil.HTMLFormat(``, src, sandboxAttrValue) - _, err := io.WriteString(output, string(iframe)) + _, err := htmlutil.HTMLPrintf(output, ``, src, extraAttrs) return err } @@ -232,16 +238,17 @@ func getExternalRendererOptions(renderer Renderer) (ret ExternalRendererOptions, func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error { var extraHeadHTML template.HTML if extOpts, ok := getExternalRendererOptions(renderer); ok && extOpts.DisplayInIframe { - if !ctx.RenderOptions.InStandalonePage { + if ctx.RenderOptions.StandalonePageOptions == nil { // for an external "DisplayInIFrame" render, it could only output its content in a standalone page // otherwise, a `, ret) + + ret = render(ctx, ExternalRendererOptions{ContentSandbox: "allow"}) + assert.Equal(t, ``, ret) +} diff --git a/modules/public/manifest.go b/modules/public/manifest.go index 77e89599672..a07cabd6cf1 100644 --- a/modules/public/manifest.go +++ b/modules/public/manifest.go @@ -125,27 +125,33 @@ func getManifestData() *manifestDataStruct { return data } -// getHashedPath resolves an unhashed asset path (origin path) to its content-hashed path from the frontend manifest. -// Example: getHashedPath("js/index.js") returns "js/index.C6Z2MRVQ.js" -// Falls back to returning the input path unchanged if the manifest is unavailable. -func getHashedPath(originPath string) string { - data := getManifestData() - if p, ok := data.paths[originPath]; ok { - return p - } - return originPath -} - // AssetURI returns the URI for a frontend asset. // It may return a relative path or a full URL depending on the StaticURLPrefix setting. // In Vite dev mode, known entry points are mapped to their source paths // so the reverse proxy serves them from the Vite dev server. // In production, it resolves the content-hashed path from the manifest. func AssetURI(originPath string) string { - if src := viteDevSourceURL(originPath); src != "" { - return src + if IsViteDevMode() { + if src := viteDevSourceURL(originPath); src != "" { + return src + } + // it should be caused by incorrect vite config + setting.PanicInDevOrTesting("Failed to locate local path for managed asset URI: %s", originPath) } - return setting.StaticURLPrefix + "/assets/" + getHashedPath(originPath) + + // Try to resolve an unhashed asset path (origin path) to its content-hashed path from the frontend manifest. + // Example: "js/index.js" -> "js/index.C6Z2MRVQ.js" + data := getManifestData() + assetPath := data.paths[originPath] + if assetPath == "" { + // it should be caused by either: "incorrect vite config" or "user's custom theme" + assetPath = originPath + if !setting.IsProd { + log.Warn("Failed to find managed asset URI for origin path: %s", originPath) + } + } + + return setting.StaticURLPrefix + "/assets/" + assetPath } // AssetNameFromHashedPath returns the asset entry name for a given hashed asset path. diff --git a/modules/public/manifest_test.go b/modules/public/manifest_test.go index 20a2232cf38..acfeaa6dbeb 100644 --- a/modules/public/manifest_test.go +++ b/modules/public/manifest_test.go @@ -24,13 +24,6 @@ func TestViteManifest(t *testing.T) { "isEntry": true, "css": ["css/index.B3zrQPqD.css"] }, - "web_src/js/standalone/swagger.ts": { - "file": "js/swagger.SujiEmYM.js", - "name": "swagger", - "src": "web_src/js/standalone/swagger.ts", - "isEntry": true, - "css": ["css/swagger._-APWT_3.css"] - }, "web_src/css/themes/theme-gitea-dark.css": { "file": "css/theme-gitea-dark.CyAaQnn5.css", "name": "theme-gitea-dark", @@ -62,12 +55,10 @@ func TestViteManifest(t *testing.T) { // JS entries assert.Equal(t, "js/index.C6Z2MRVQ.js", paths["js/index.js"]) - assert.Equal(t, "js/swagger.SujiEmYM.js", paths["js/swagger.js"]) assert.Equal(t, "js/eventsource.sharedworker.Dug1twio.js", paths["js/eventsource.sharedworker.js"]) // Associated CSS from JS entries assert.Equal(t, "css/index.B3zrQPqD.css", paths["css/index.css"]) - assert.Equal(t, "css/swagger._-APWT_3.css", paths["css/swagger.css"]) // CSS-only entries assert.Equal(t, "css/theme-gitea-dark.CyAaQnn5.css", paths["css/theme-gitea-dark.css"]) @@ -78,8 +69,6 @@ func TestViteManifest(t *testing.T) { // Names: hashed path -> entry name assert.Equal(t, "index", names["js/index.C6Z2MRVQ.js"]) assert.Equal(t, "index", names["css/index.B3zrQPqD.css"]) - assert.Equal(t, "swagger", names["js/swagger.SujiEmYM.js"]) - assert.Equal(t, "swagger", names["css/swagger._-APWT_3.css"]) assert.Equal(t, "theme-gitea-dark", names["css/theme-gitea-dark.CyAaQnn5.css"]) assert.Equal(t, "eventsource.sharedworker", names["js/eventsource.sharedworker.Dug1twio.js"]) diff --git a/modules/public/public.go b/modules/public/public.go index 004aad5f3b1..bb4721a48d3 100644 --- a/modules/public/public.go +++ b/modules/public/public.go @@ -18,6 +18,8 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" + + "github.com/go-chi/cors" ) func CustomAssets() *assetfs.Layer { @@ -28,6 +30,15 @@ func AssetFS() *assetfs.LayeredFS { return assetfs.Layered(CustomAssets(), BuiltinAssets()) } +func AssetsCors() func(next http.Handler) http.Handler { + // static assets need to be served for external renders (sandboxed) + return cors.Handler(cors.Options{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"HEAD", "GET"}, + MaxAge: 3600 * 24, + }) +} + // FileHandlerFunc implements the static handler for serving files in "public" assets func FileHandlerFunc() http.HandlerFunc { assetFS := AssetFS() diff --git a/modules/public/vitedev.go b/modules/public/vitedev.go index 25bd28a8265..7cfe692390b 100644 --- a/modules/public/vitedev.go +++ b/modules/public/vitedev.go @@ -16,6 +16,7 @@ import ( "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web/routing" ) @@ -70,6 +71,9 @@ func getViteDevProxy() *httputil.ReverseProxy { return nil }, ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + if r.Context().Err() != nil { + return // request cancelled (e.g. client disconnected), silently ignore + } log.Error("Error proxying to Vite dev server: %v", err) http.Error(w, "Error proxying to Vite dev server: "+err.Error(), http.StatusBadGateway) }, @@ -136,34 +140,33 @@ func IsViteDevMode() bool { return isDev } -func viteDevSourceURL(name string) string { - if !IsViteDevMode() { - return "" - } - if strings.HasPrefix(name, "css/theme-") { - // Only redirect built-in themes to Vite source; custom themes are served from custom/public/assets/css/ - themeFile := strings.TrimPrefix(name, "css/") - srcPath := filepath.Join(setting.StaticRootPath, "web_src/css/themes", themeFile) - if _, err := os.Stat(srcPath); err == nil { - return setting.AppSubURL + "/web_src/css/themes/" + themeFile - } - return "" - } - if strings.HasPrefix(name, "css/") { - return setting.AppSubURL + "/web_src/" + name - } - if name == "js/eventsource.sharedworker.js" { - return setting.AppSubURL + "/web_src/js/features/eventsource.sharedworker.ts" - } - if name == "js/iife.js" { - return setting.AppSubURL + "/web_src/js/__vite_iife.js" - } - if name == "js/index.js" { - return setting.AppSubURL + "/web_src/js/index.ts" +func detectWebSrcPath(webSrcPath string) string { + localPath := util.FilePathJoinAbs(setting.StaticRootPath, "web_src", webSrcPath) + if _, err := os.Stat(localPath); err == nil { + return setting.AppSubURL + "/web_src/" + webSrcPath } return "" } +func viteDevSourceURL(name string) string { + if strings.HasPrefix(name, "css/theme-") { + // Only redirect built-in themes to Vite source; custom themes are served from custom/public/assets/css/ + themeFilePath := "css/themes/" + strings.TrimPrefix(name, "css/") + if srcPath := detectWebSrcPath(themeFilePath); srcPath != "" { + return srcPath + } + } + // try to map ".js" files to ".ts" files + pathPrefix, ok := strings.CutSuffix(name, ".js") + if ok { + if srcPath := detectWebSrcPath(pathPrefix + ".ts"); srcPath != "" { + return srcPath + } + } + // for all others that the names match + return detectWebSrcPath(name) +} + // isViteDevRequest returns true if the request should be proxied to the Vite dev server. // Ref: Vite source packages/vite/src/node/constants.ts and packages/vite/src/shared/constants.ts func isViteDevRequest(req *http.Request) bool { diff --git a/routers/web/misc/swagger.go b/routers/web/misc/swagger.go index 1ca347551c1..4abd4f042d6 100644 --- a/routers/web/misc/swagger.go +++ b/routers/web/misc/swagger.go @@ -6,15 +6,9 @@ package misc import ( "net/http" - "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/services/context" ) -// tplSwagger swagger page template -const tplSwagger templates.TplName = "swagger/ui" - -// Swagger render swagger-ui page with v1 json func Swagger(ctx *context.Context) { - ctx.Data["APIJSONVersion"] = "v1" - ctx.HTML(http.StatusOK, tplSwagger) + ctx.HTML(http.StatusOK, "swagger/openapi-viewer") } diff --git a/routers/web/repo/render.go b/routers/web/repo/render.go index b1299c7047e..160f6315855 100644 --- a/routers/web/repo/render.go +++ b/routers/web/repo/render.go @@ -42,7 +42,9 @@ func RenderFile(ctx *context.Context) { rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{ CurrentRefPath: ctx.Repo.RefTypeNameSubURL(), CurrentTreePath: path.Dir(ctx.Repo.TreePath), - }).WithRelativePath(ctx.Repo.TreePath).WithInStandalonePage(true) + }).WithRelativePath(ctx.Repo.TreePath).WithStandalonePage(markup.StandalonePageOptions{ + CurrentWebTheme: ctx.TemplateContext.CurrentWebTheme(), + }) renderer, rendererInput, err := rctx.DetectMarkupRendererByReader(blobReader) if err != nil { http.Error(ctx.Resp, "Unable to find renderer", http.StatusBadRequest) diff --git a/routers/web/web.go b/routers/web/web.go index e3dcf27cc4a..86397bb9394 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -260,7 +260,7 @@ func Routes() *web.Router { routes.BeforeRouting(chi_middleware.GetHead) routes.Head("/", misc.DummyOK) // for health check - doesn't need to be passed through gzip handler - routes.Methods("GET, HEAD, OPTIONS", "/assets/*", routing.MarkLogLevelTrace, optionsCorsHandler(), public.FileHandlerFunc()) + routes.Methods("GET, HEAD, OPTIONS", "/assets/*", routing.MarkLogLevelTrace, public.AssetsCors(), public.FileHandlerFunc()) routes.Methods("GET, HEAD", "/avatars/*", avatarStorageHandler(setting.Avatar.Storage, "avatars", storage.Avatars)) routes.Methods("GET, HEAD", "/repo-avatars/*", avatarStorageHandler(setting.RepoAvatar.Storage, "repo-avatars", storage.RepoAvatars)) routes.Methods("GET, HEAD", "/apple-touch-icon.png", misc.StaticRedirect("/assets/img/apple-touch-icon.png")) diff --git a/services/webtheme/webtheme.go b/services/webtheme/webtheme.go index f8322381ca1..e82008c6871 100644 --- a/services/webtheme/webtheme.go +++ b/services/webtheme/webtheme.go @@ -5,6 +5,7 @@ package webtheme import ( "io/fs" + "net/url" "os" "path" "regexp" @@ -43,6 +44,10 @@ type ThemeMetaInfo struct { ColorScheme string } +func (info *ThemeMetaInfo) PublicAssetURI() string { + return public.AssetURI("css/theme-" + url.PathEscape(info.InternalName) + ".css") +} + func (info *ThemeMetaInfo) GetDescription() string { if info.ColorblindType == "red-green" { return "Red-green colorblind friendly" diff --git a/stylelint.config.js b/stylelint.config.js index 3e6be3c2487..0aee1a5dac2 100644 --- a/stylelint.config.js +++ b/stylelint.config.js @@ -26,7 +26,7 @@ export default { ], overrides: [ { - files: ['**/chroma/*', '**/codemirror/*', '**/standalone/*', '**/console.css', 'font_i18n.css'], + files: ['**/chroma/*', '**/codemirror/*', '**/console.css', 'font_i18n.css'], rules: { 'scale-unlimited/declaration-strict-value': null, }, diff --git a/tailwind.config.ts b/tailwind.config.ts index 7aaea687eb3..415837f8922 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,11 +1,8 @@ import {readFileSync} from 'node:fs'; -import {env} from 'node:process'; import {parse} from 'postcss'; import plugin from 'tailwindcss/plugin.js'; import type {Config} from 'tailwindcss'; -const isProduction = env.NODE_ENV !== 'development'; - function extractRootVars(css: string) { const root = parse(css); const vars = new Set(); @@ -29,8 +26,6 @@ export default { prefix: 'tw-', important: true, // the frameworks are mixed together, so tailwind needs to override other framework's styles content: [ - isProduction && '!./templates/devtest/**/*', - isProduction && '!./web_src/js/standalone/devtest.ts', '!./templates/swagger/v1_json.tmpl', '!./templates/user/auth/oidc_wellknown.tmpl', '!**/*_test.go', diff --git a/templates/base/head_style.tmpl b/templates/base/head_style.tmpl index 15fa7ad730c..4a4fb9d96f8 100644 --- a/templates/base/head_style.tmpl +++ b/templates/base/head_style.tmpl @@ -1,2 +1,2 @@ - + diff --git a/templates/devtest/devtest-footer.tmpl b/templates/devtest/devtest-footer.tmpl index 868136e1948..091a1035a9f 100644 --- a/templates/devtest/devtest-footer.tmpl +++ b/templates/devtest/devtest-footer.tmpl @@ -1,3 +1 @@ -{{/* TODO: the devtest.js is isolated from index.js, so no module is shared and many index.js functions do not work in devtest.ts */}} - {{template "base/footer" ctx.RootData}} diff --git a/templates/devtest/devtest-header.tmpl b/templates/devtest/devtest-header.tmpl index a7aebcb7dc8..628e4388a0c 100644 --- a/templates/devtest/devtest-header.tmpl +++ b/templates/devtest/devtest-header.tmpl @@ -1,8 +1,4 @@ {{template "base/head" ctx.RootData}} - +
{{template "base/alert" .}} diff --git a/templates/swagger/ui.tmpl b/templates/swagger/openapi-viewer.tmpl similarity index 51% rename from templates/swagger/ui.tmpl rename to templates/swagger/openapi-viewer.tmpl index d53a6111764..0360f169531 100644 --- a/templates/swagger/ui.tmpl +++ b/templates/swagger/openapi-viewer.tmpl @@ -2,13 +2,15 @@ Gitea API - + {{/* HINT: SWAGGER-OPENAPI-VIEWER: another place is "modules/markup/external/openapi.go" */}} + + {{/* TODO: add Help & Glossary to help users understand the API, and explain some concepts like "Owner" */}} {{svg "octicon-reply"}}{{ctx.Locale.Tr "return_to_gitea"}} -
+
- + {{ScriptImport "js/swagger.js" "module"}} diff --git a/tests/e2e/external-render.test.ts b/tests/e2e/external-render.test.ts new file mode 100644 index 00000000000..50adb6429e0 --- /dev/null +++ b/tests/e2e/external-render.test.ts @@ -0,0 +1,45 @@ +import {env} from 'node:process'; +import {expect, test} from '@playwright/test'; +import {login, apiCreateRepo, apiCreateFile, apiDeleteRepo, assertNoJsError, randomString} from './utils.ts'; + +test('external file', async ({page, request}) => { + const repoName = `e2e-external-render-${randomString(8)}`; + const owner = env.GITEA_TEST_E2E_USER; + await Promise.all([ + apiCreateRepo(request, {name: repoName}), + login(page), + ]); + try { + await apiCreateFile(request, owner, repoName, 'test.external', '

rendered content

'); + await page.goto(`/${owner}/${repoName}/src/branch/main/test.external`); + const iframe = page.locator('iframe.external-render-iframe'); + await expect(iframe).toBeVisible(); + await expect(iframe).toHaveAttribute('data-src', new RegExp(`/${owner}/${repoName}/render/branch/main/test\\.external`)); + const frame = page.frameLocator('iframe.external-render-iframe'); + await expect(frame.locator('p')).toContainText('rendered content'); + await assertNoJsError(page); + } finally { + await apiDeleteRepo(request, owner, repoName); + } +}); + +test('openapi file', async ({page, request}) => { + const repoName = `e2e-openapi-render-${randomString(8)}`; + const owner = env.GITEA_TEST_E2E_USER; + await Promise.all([ + apiCreateRepo(request, {name: repoName}), + login(page), + ]); + try { + const spec = 'openapi: "3.0.0"\ninfo:\n title: Test API\n version: "1.0"\npaths: {}\n'; + await apiCreateFile(request, owner, repoName, 'openapi.yaml', spec); + await page.goto(`/${owner}/${repoName}/src/branch/main/openapi.yaml`); + const iframe = page.locator('iframe.external-render-iframe'); + await expect(iframe).toBeVisible(); + const frame = page.frameLocator('iframe.external-render-iframe'); + await expect(frame.locator('#swagger-ui .swagger-ui')).toBeVisible(); + await assertNoJsError(page); + } finally { + await apiDeleteRepo(request, owner, repoName); + } +}); diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index 442a40d8e56..7a4a91c2699 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -60,6 +60,13 @@ export async function apiStartStopwatch(requestContext: APIRequestContext, owner }), 'apiStartStopwatch'); } +export async function apiCreateFile(requestContext: APIRequestContext, owner: string, repo: string, filepath: string, content: string) { + await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/contents/${filepath}`, { + headers: apiHeaders(), + data: {content: globalThis.btoa(content)}, + }), 'apiCreateFile'); +} + export async function apiDeleteRepo(requestContext: APIRequestContext, owner: string, name: string) { await apiRetry(() => requestContext.delete(`${baseUrl()}/api/v1/repos/${owner}/${name}`, { headers: apiHeaders(), diff --git a/tests/integration/markup_external_test.go b/tests/integration/markup_external_test.go index 3d9d7b39696..8baa266962d 100644 --- a/tests/integration/markup_external_test.go +++ b/tests/integration/markup_external_test.go @@ -108,7 +108,7 @@ func TestExternalMarkupRenderer(t *testing.T) { // default sandbox in sub page response assert.Equal(t, "frame-src 'self'; sandbox allow-scripts allow-popups", respSub.Header().Get("Content-Security-Policy")) // FIXME: actually here is a bug (legacy design problem), the "PostProcess" will escape "
<script></script>
`, respSub.Body.String()) + assert.Equal(t, `
<script></script>
`, respSub.Body.String()) }) }) @@ -131,7 +131,7 @@ func TestExternalMarkupRenderer(t *testing.T) { t.Run("HTMLContentWithExternalRenderIframeHelper", func(t *testing.T) { req := NewRequest(t, "GET", "/user2/repo1/render/branch/master/html.no-sanitizer") respSub := MakeRequest(t, req, http.StatusOK) - assert.Equal(t, ``, respSub.Body.String()) + assert.Equal(t, ``, respSub.Body.String()) assert.Equal(t, "frame-src 'self'", respSub.Header().Get("Content-Security-Policy")) }) }) diff --git a/tools/test-e2e.sh b/tools/test-e2e.sh index ed3d8c402e4..b8bf6718392 100755 --- a/tools/test-e2e.sh +++ b/tools/test-e2e.sh @@ -40,6 +40,13 @@ EVENT_SOURCE_UPDATE_TIME = 500ms [log] MODE = console LEVEL = Warn + +[markup.test-external] +ENABLED = true +FILE_EXTENSIONS = .external +RENDER_COMMAND = cat +IS_INPUT_FILE = false +RENDER_CONTENT_MODE = iframe EOF export GITEA_WORK_DIR="$WORK_DIR" diff --git a/vite.config.ts b/vite.config.ts index 375c127ce09..cc446f6558a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,13 +2,14 @@ import {build, defineConfig} from 'vite'; import vuePlugin from '@vitejs/plugin-vue'; import {stringPlugin} from 'vite-string-plugin'; import {readFileSync, writeFileSync, mkdirSync, unlinkSync, globSync} from 'node:fs'; -import path, {join, parse} from 'node:path'; +import path, {basename, join, parse} from 'node:path'; import {env} from 'node:process'; import tailwindcss from 'tailwindcss'; import tailwindConfig from './tailwind.config.ts'; import wrapAnsi from 'wrap-ansi'; import licensePlugin from 'rollup-plugin-license'; import type {InlineConfig, Plugin, Rolldown} from 'vite'; +import {camelize} from 'vue'; const isProduction = env.NODE_ENV !== 'development'; @@ -76,13 +77,14 @@ function commonViteOpts({build, ...other}: InlineConfig): InlineConfig { }; } -const iifeEntry = join(import.meta.dirname, 'web_src/js/iife.ts'); - -function iifeBuildOpts({entryFileNames, write}: {entryFileNames: string, write?: boolean}) { +function iifeBuildOpts({sourceFileName, write}: {sourceFileName: string, write?: boolean}) { + const sourceBaseName = basename(sourceFileName, '.ts'); + // HINT: VITE-OUTPUT-DIR: all outputted JS files are in "js" directory + const entryFileName = `js/${sourceBaseName}.[hash:8].js`; return commonViteOpts({ build: { - lib: {entry: iifeEntry, formats: ['iife'], name: 'iife'}, - rolldownOptions: {output: {entryFileNames}}, + lib: {entry: join(import.meta.dirname, 'web_src/js', sourceFileName), name: camelize(sourceBaseName), formats: ['iife']}, + rolldownOptions: {output: {entryFileNames: entryFileName}}, ...(write === false && {write: false}), }, plugins: [stringPlugin()], @@ -91,19 +93,20 @@ function iifeBuildOpts({entryFileNames, write}: {entryFileNames: string, write?: // Build iife.js as a blocking IIFE bundle. In dev mode, serves it from memory // and rebuilds on file changes. In prod mode, writes to disk during closeBundle. -function iifePlugin(): Plugin { - let iifeCode = ''; - let iifeMap = ''; +function iifePlugin(sourceFileName: string): Plugin { + let iifeCode = '', iifeMap = ''; const iifeModules = new Set(); let isBuilding = false; + + const sourceBaseName = path.basename(sourceFileName, '.ts'); return { - name: 'iife', + name: `iife:${sourceFileName}`, // plugin name async configureServer(server) { const buildAndCache = async () => { - const result = await build(iifeBuildOpts({entryFileNames: 'js/iife.js', write: false})); + const result = await build(iifeBuildOpts({sourceFileName, write: false})); const output = (Array.isArray(result) ? result[0] : result) as Rolldown.RolldownOutput; const chunk = output.output[0]; - iifeCode = chunk.code.replace(/\/\/# sourceMappingURL=.*/, '//# sourceMappingURL=__vite_iife.js.map'); + iifeCode = chunk.code.replace(/\/\/# sourceMappingURL=.*/, `//# sourceMappingURL=${sourceBaseName}.js.map`); const mapAsset = output.output.find((o) => o.fileName.endsWith('.map')); iifeMap = mapAsset && 'source' in mapAsset ? String(mapAsset.source) : ''; iifeModules.clear(); @@ -129,15 +132,15 @@ function iifePlugin(): Plugin { }); server.middlewares.use((req, res, next) => { - // "__vite_iife" is a virtual file in memory, serve it directly + // on the dev server, an "iife" file is a virtual file in memory, serve it directly const pathname = req.url!.split('?')[0]; if (pathname === '/web_src/js/__vite_dev_server_check') { res.end('ok'); - } else if (pathname === '/web_src/js/__vite_iife.js') { + } else if (pathname === `/web_src/js/${sourceFileName}`) { res.setHeader('Content-Type', 'application/javascript'); res.setHeader('Cache-Control', 'no-store'); res.end(iifeCode); - } else if (pathname === '/web_src/js/__vite_iife.js.map') { + } else if (pathname === `/web_src/js/${sourceBaseName}.js.map`) { res.setHeader('Content-Type', 'application/json'); res.setHeader('Cache-Control', 'no-store'); res.end(iifeMap); @@ -147,29 +150,38 @@ function iifePlugin(): Plugin { }); }, async closeBundle() { - for (const file of globSync('js/iife.*.js*', {cwd: outDir})) unlinkSync(join(outDir, file)); - const result = await build(iifeBuildOpts({entryFileNames: 'js/iife.[hash:8].js'})); + for (const file of globSync(`js/${sourceBaseName}.*.js*`, {cwd: outDir})) unlinkSync(join(outDir, file)); + + const result = await build(iifeBuildOpts({sourceFileName})); const buildOutput = (Array.isArray(result) ? result[0] : result) as Rolldown.RolldownOutput; - const entry = buildOutput.output.find((o) => o.fileName.startsWith('js/iife.')); + const entry = buildOutput.output.find((o) => o.fileName.startsWith(`js/${sourceBaseName}.`)); if (!entry) throw new Error('IIFE build produced no output'); + const manifestPath = join(outDir, '.vite', 'manifest.json'); - writeFileSync(manifestPath, JSON.stringify({ - ...JSON.parse(readFileSync(manifestPath, 'utf8')), - 'web_src/js/iife.ts': {file: entry.fileName, name: 'iife', isEntry: true}, - }, null, 2)); + const manifestData = JSON.parse(readFileSync(manifestPath, 'utf8')); + manifestData[`web_src/js/${sourceFileName}`] = {file: entry.fileName, name: sourceBaseName, isEntry: true}; + writeFileSync(manifestPath, JSON.stringify(manifestData, null, 2)); }, }; } // In reduced sourcemap mode, only keep sourcemaps for main files function reducedSourcemapPlugin(): Plugin { + const standalonePrefixes = [ + 'js/index.', + 'js/iife.', + 'js/swagger.', + 'js/external-render-helper.', + 'js/eventsource.sharedworker.', + ]; return { name: 'reduced-sourcemap', apply: 'build', closeBundle() { if (enableSourcemap !== 'reduced') return; for (const file of globSync('{js,css}/*.map', {cwd: outDir})) { - if (!file.startsWith('js/index.') && !file.startsWith('js/iife.')) unlinkSync(join(outDir, file)); + if (standalonePrefixes.some((prefix) => file.startsWith(prefix))) continue; + unlinkSync(join(outDir, file)); } }, }; @@ -215,6 +227,7 @@ export default defineConfig(commonViteOpts({ open: false, host: '0.0.0.0', strictPort: false, + cors: true, fs: { // VITE-DEV-SERVER-SECURITY: the dev server will be exposed to public by Gitea's web server, so we need to strictly limit the access // Otherwise `/@fs/*` will be able to access any file (including app.ini which contains INTERNAL_TOKEN) @@ -245,15 +258,15 @@ export default defineConfig(commonViteOpts({ rolldownOptions: { input: { index: join(import.meta.dirname, 'web_src/js/index.ts'), - swagger: join(import.meta.dirname, 'web_src/js/standalone/swagger.ts'), - 'external-render-iframe': join(import.meta.dirname, 'web_src/js/standalone/external-render-iframe.ts'), - 'eventsource.sharedworker': join(import.meta.dirname, 'web_src/js/features/eventsource.sharedworker.ts'), - ...(!isProduction && { - devtest: join(import.meta.dirname, 'web_src/js/standalone/devtest.ts'), - }), + swagger: join(import.meta.dirname, 'web_src/js/swagger.ts'), + 'eventsource.sharedworker': join(import.meta.dirname, 'web_src/js/eventsource.sharedworker.ts'), + devtest: join(import.meta.dirname, 'web_src/css/devtest.css'), ...themes, }, output: { + // HINT: VITE-OUTPUT-DIR: all outputted JS files are in "js" directory + // So standalone/iife source files should also be in "js" directory, + // to keep consistent between production and dev server, avoid unexpected behaviors. entryFileNames: 'js/[name].[hash:8].js', chunkFileNames: 'js/[name].[hash:8].js', assetFileNames: ({names}) => { @@ -287,7 +300,8 @@ export default defineConfig(commonViteOpts({ __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false, }, plugins: [ - iifePlugin(), + iifePlugin('iife.ts'), + iifePlugin('external-render-helper.ts'), viteDevServerPortPlugin(), reducedSourcemapPlugin(), filterCssUrlPlugin(), diff --git a/web_src/css/standalone/devtest.css b/web_src/css/devtest.css similarity index 100% rename from web_src/css/standalone/devtest.css rename to web_src/css/devtest.css diff --git a/web_src/css/markup/content.css b/web_src/css/markup/content.css index d0655af0027..e7a967a7c64 100644 --- a/web_src/css/markup/content.css +++ b/web_src/css/markup/content.css @@ -529,6 +529,7 @@ html[data-gitea-theme-dark="false"] .markup img[src*="#gh-dark-mode-only"] { .external-render-iframe { width: 100%; height: max(300px, 80vh); + border: none; } .markup-content-iframe { diff --git a/web_src/css/standalone/external-render-iframe.css b/web_src/css/standalone/external-render-iframe.css deleted file mode 100644 index 2997587d820..00000000000 --- a/web_src/css/standalone/external-render-iframe.css +++ /dev/null @@ -1 +0,0 @@ -/* dummy */ diff --git a/web_src/css/standalone/swagger.css b/web_src/css/standalone/swagger.css deleted file mode 100644 index e65af5ded63..00000000000 --- a/web_src/css/standalone/swagger.css +++ /dev/null @@ -1,46 +0,0 @@ -*, -*::before, -*::after { - box-sizing: border-box; -} - -body { - margin: 0; - background: #fff; -} - -.swagger-back-link { - color: #4990e2; - text-decoration: none; - position: absolute; - top: 1rem; - right: 1.5rem; - display: flex; - align-items: center; -} - -@media (prefers-color-scheme: dark) { - body { - background: #1c2022; - } - .swagger-back-link { - color: #51a8ff; - } - .swagger-ui table.headers td { - color: #aeb4c4; /** fix low contrast */ - } -} - -.swagger-back-link:hover { - text-decoration: underline; -} - -.swagger-back-link svg { - color: inherit; - fill: currentcolor; - margin-right: 0.5rem; -} - -.swagger-spec-content { - display: none; -} diff --git a/web_src/css/swagger.css b/web_src/css/swagger.css new file mode 100644 index 00000000000..c20eda7948d --- /dev/null +++ b/web_src/css/swagger.css @@ -0,0 +1,41 @@ +@import "../../node_modules/swagger-ui-dist/swagger-ui.css"; + +body { + margin: 0; +} + +html, +html body, +html .swagger-ui, +html .swagger-ui .scheme-container { + background: var(--gitea-iframe-bgcolor, var(--color-box-body)) !important; +} + +/* swagger's bug: the selector was incorrectly written in "thead": "html.dark-mode .swagger-ui .opblock.opblock-get thead tr td" */ +html.dark-mode .swagger-ui table.headers td { + color: var(--color-text) !important; +} + +.swagger-back-link { + color: var(--color-primary); + text-decoration: none; + position: absolute; + top: 1rem; + right: 1.5rem; + display: flex; + align-items: center; +} + +.swagger-back-link:hover { + text-decoration: underline; +} + +.swagger-back-link svg { + color: inherit; + fill: currentcolor; + margin-right: 0.5rem; +} + +.swagger-spec-content { + display: none; +} diff --git a/web_src/js/features/eventsource.sharedworker.ts b/web_src/js/eventsource.sharedworker.ts similarity index 100% rename from web_src/js/features/eventsource.sharedworker.ts rename to web_src/js/eventsource.sharedworker.ts diff --git a/web_src/js/standalone/external-render-iframe.ts b/web_src/js/external-render-helper.ts similarity index 61% rename from web_src/js/standalone/external-render-iframe.ts rename to web_src/js/external-render-helper.ts index 3b489f8ee38..3acac8db141 100644 --- a/web_src/js/standalone/external-render-iframe.ts +++ b/web_src/js/external-render-helper.ts @@ -1,3 +1,7 @@ +// External render JS must be a IIFE module to run as early as possible to set up the environment for the content page. +// Avoid unnecessary dependency. +// Do NOT introduce global pollution, because the content page should be fully controlled by the external render. + /* To manually test: [markup.in-iframe] @@ -11,22 +15,39 @@ RENDER_COMMAND = `echo '
Toast | null>; + +export function initDevtest() { + registerGlobalInitFunc('initDevtestPage', () => { + const els = document.querySelectorAll('.toast-test-button'); + if (!els.length) return; + const levelMap: LevelMap = {info: showInfoToast, warning: showWarningToast, error: showErrorToast}; + for (const el of els) { + el.addEventListener('click', () => { + const level = el.getAttribute('data-toast-level')!; + const message = el.getAttribute('data-toast-message')!; + levelMap[level](message); + }); + } + }); +} diff --git a/web_src/js/standalone/devtest.ts b/web_src/js/standalone/devtest.ts deleted file mode 100644 index 20ab163d1a2..00000000000 --- a/web_src/js/standalone/devtest.ts +++ /dev/null @@ -1,18 +0,0 @@ -import '../../css/standalone/devtest.css'; -import {showInfoToast, showWarningToast, showErrorToast, type Toast} from '../modules/toast.ts'; - -type LevelMap = Record Toast | null>; - -function initDevtestToast() { - const levelMap: LevelMap = {info: showInfoToast, warning: showWarningToast, error: showErrorToast}; - for (const el of document.querySelectorAll('.toast-test-button')) { - el.addEventListener('click', () => { - const level = el.getAttribute('data-toast-level')!; - const message = el.getAttribute('data-toast-message')!; - levelMap[level](message); - }); - } -} - -// NOTICE: keep in mind that this file is not in "index.js", they do not share the same module system. -initDevtestToast(); diff --git a/web_src/js/standalone/swagger.ts b/web_src/js/standalone/swagger.ts deleted file mode 100644 index fc44c8501ac..00000000000 --- a/web_src/js/standalone/swagger.ts +++ /dev/null @@ -1,48 +0,0 @@ -import '../../css/standalone/swagger.css'; -import SwaggerUI from 'swagger-ui-dist/swagger-ui-es-bundle.js'; -import 'swagger-ui-dist/swagger-ui.css'; -import {load as loadYaml} from 'js-yaml'; - -const prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); -const apply = () => document.documentElement.classList.toggle('dark-mode', prefersDark.matches); -apply(); -prefersDark.addEventListener('change', apply); - -window.addEventListener('load', async () => { - const elSwaggerUi = document.querySelector('#swagger-ui')!; - const url = elSwaggerUi.getAttribute('data-source')!; - let spec: any; - if (url) { - const res = await fetch(url); // eslint-disable-line no-restricted-globals - spec = await res.json(); - } else { - const elSpecContent = elSwaggerUi.querySelector('.swagger-spec-content')!; - const filename = elSpecContent.getAttribute('data-spec-filename'); - const isJson = filename?.toLowerCase().endsWith('.json'); - spec = isJson ? JSON.parse(elSpecContent.value) : loadYaml(elSpecContent.value); - } - - // Make the page's protocol be at the top of the schemes list - const proto = window.location.protocol.slice(0, -1); - if (spec?.schemes) { - spec.schemes.sort((a: string, b: string) => { - if (a === proto) return -1; - if (b === proto) return 1; - return 0; - }); - } - - SwaggerUI({ - spec, - dom_id: '#swagger-ui', - deepLinking: true, - docExpansion: 'none', - defaultModelRendering: 'model', // don't show examples by default, because they may be incomplete - presets: [ - SwaggerUI.presets.apis, - ], - plugins: [ - SwaggerUI.plugins.DownloadUrl, - ], - }); -}); diff --git a/web_src/js/swagger.ts b/web_src/js/swagger.ts new file mode 100644 index 00000000000..b2f6a61030a --- /dev/null +++ b/web_src/js/swagger.ts @@ -0,0 +1,70 @@ +// AVOID importing other unneeded main site JS modules to prevent unnecessary code and dependencies and chunks. +// +// Swagger JS is standalone because it is also used by external render like "File View -> OpenAPI render", +// and it doesn't need any code from main site's modules (at the moment). +// +// In the future, if there are common utilities needed by both main site and standalone Swagger, +// we can merge this standalone module into "index.ts", do pay attention to the following problems: +// * HINT: SWAGGER-OPENAPI-VIEWER: there are different places rendering the swagger UI. +// * Handle CSS styles carefully for different cases (standalone page, embedded in iframe) +// * Take care of the JS code introduced by "index.ts" and "iife.ts", there might be global variable dependency and event listeners. + +import '../css/swagger.css'; +import SwaggerUI from 'swagger-ui-dist/swagger-ui-es-bundle.js'; +import 'swagger-ui-dist/swagger-ui.css'; +import {load as loadYaml} from 'js-yaml'; + +function syncDarkModeClass(): void { + // if the viewer is embedded in an iframe (external render), use the parent's theme (passed via query param) + // otherwise, if it is for Gitea's API, it is a standalone page, use the site's theme (detected from theme CSS variable) + const url = new URL(window.location.href); + const giteaIsDarkTheme = url.searchParams.get('gitea-is-dark-theme') ?? + window.getComputedStyle(document.documentElement).getPropertyValue('--is-dark-theme').trim(); + const isDark = giteaIsDarkTheme ? giteaIsDarkTheme === 'true' : window.matchMedia('(prefers-color-scheme: dark)').matches; + document.documentElement.classList.toggle('dark-mode', isDark); +} + +async function initSwaggerUI() { + // swagger-ui has built-in dark mode triggered by html.dark-mode class + syncDarkModeClass(); + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncDarkModeClass); + + const elSwaggerUi = document.querySelector('#swagger-ui')!; + const url = elSwaggerUi.getAttribute('data-source')!; + let spec: any; + if (url) { + const res = await fetch(url); // eslint-disable-line no-restricted-globals + spec = await res.json(); + } else { + const elSpecContent = elSwaggerUi.querySelector('.swagger-spec-content')!; + const filename = elSpecContent.getAttribute('data-spec-filename'); + const isJson = filename?.toLowerCase().endsWith('.json'); + spec = isJson ? JSON.parse(elSpecContent.value) : loadYaml(elSpecContent.value); + } + + // Make the page's protocol be at the top of the schemes list + const proto = window.location.protocol.slice(0, -1); + if (spec?.schemes) { + spec.schemes.sort((a: string, b: string) => { + if (a === proto) return -1; + if (b === proto) return 1; + return 0; + }); + } + + SwaggerUI({ + spec, + dom_id: '#swagger-ui', + deepLinking: true, + docExpansion: 'none', + defaultModelRendering: 'model', // don't show examples by default, because they may be incomplete + presets: [ + SwaggerUI.presets.apis, + ], + plugins: [ + SwaggerUI.plugins.DownloadUrl, + ], + }); +} + +initSwaggerUI(); From ca51b4f875276b9bae8816d550e60e2657c96056 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 5 Apr 2026 21:51:51 +0200 Subject: [PATCH 3/4] Move package settings to package instead of being tied to version (#37026) Unties settings page from package version and adds button to delete the package version Settings page now allows for deletion of entire package and it's versions as opposed to a single version Adds an API endpoint to delete the entire package with all versions from registry fixes: https://github.com/go-gitea/gitea/issues/36904 Co-Authored-By: gemini-3-flash --------- Co-authored-by: wxiaoguang Co-authored-by: Giteabot --- models/packages/descriptor.go | 22 +++- models/packages/package_file.go | 14 +++ models/packages/package_property.go | 41 +++++++ models/packages/package_version.go | 6 ++ options/locale/locale_en-US.json | 6 ++ routers/api/v1/api.go | 3 +- routers/api/v1/packages/package.go | 37 ++++++- routers/web/admin/packages.go | 2 +- routers/web/user/package.go | 35 +++++- routers/web/web.go | 9 +- services/context/package.go | 53 ++++++--- services/packages/cleanup/cleanup.go | 1 + services/packages/packages.go | 54 ++++++++-- templates/admin/packages/list.tmpl | 2 +- templates/package/settings.tmpl | 14 ++- templates/package/shared/view.tmpl | 19 +++- templates/swagger/v1_json.tmpl | 42 +++++++- tests/integration/api_packages_test.go | 44 ++++++++ tests/integration/packages_service_test.go | 118 +++++++++++++++++++++ 19 files changed, 477 insertions(+), 45 deletions(-) create mode 100644 tests/integration/packages_service_test.go diff --git a/models/packages/descriptor.go b/models/packages/descriptor.go index ea0e0d5e734..58f16c9eca8 100644 --- a/models/packages/descriptor.go +++ b/models/packages/descriptor.go @@ -9,6 +9,7 @@ import ( "fmt" "net/url" + "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/cache" @@ -53,8 +54,11 @@ func (l PackagePropertyList) GetByName(name string) string { // PackageDescriptor describes a package type PackageDescriptor struct { - Package *Package - Owner *user_model.User + // basic package info + Package *Package + Owner *user_model.User + + // package version info Repository *repo_model.Repository Version *PackageVersion SemVer *version.Version @@ -77,6 +81,11 @@ func (pd *PackageDescriptor) PackageWebLink() string { return fmt.Sprintf("%s/-/packages/%s/%s", pd.Owner.HomeLink(), string(pd.Package.Type), url.PathEscape(pd.Package.LowerName)) } +// PackageSettingsLink returns the relative package settings link +func (pd *PackageDescriptor) PackageSettingsLink() string { + return fmt.Sprintf("%s/-/packages/settings/%s/%s", pd.Owner.HomeLink(), string(pd.Package.Type), url.PathEscape(pd.Package.LowerName)) +} + // VersionWebLink returns the relative package version web link func (pd *PackageDescriptor) VersionWebLink() string { return fmt.Sprintf("%s/%s", pd.PackageWebLink(), url.PathEscape(pd.Version.LowerVersion)) @@ -267,6 +276,15 @@ func GetPackageDescriptors(ctx context.Context, pvs []*PackageVersion) ([]*Packa return getPackageDescriptors(ctx, pvs, cache.NewEphemeralCache()) } +// GetAllPackageDescriptors gets all package descriptors for a package +func GetAllPackageDescriptors(ctx context.Context, p *Package) ([]*PackageDescriptor, error) { + pvs := make([]*PackageVersion, 0, 10) + if err := db.GetEngine(ctx).Where("package_id = ?", p.ID).Find(&pvs); err != nil { + return nil, err + } + return getPackageDescriptors(ctx, pvs, cache.NewEphemeralCache()) +} + func getPackageDescriptors(ctx context.Context, pvs []*PackageVersion, c *cache.EphemeralCache) ([]*PackageDescriptor, error) { pds := make([]*PackageDescriptor, 0, len(pvs)) for _, pv := range pvs { diff --git a/models/packages/package_file.go b/models/packages/package_file.go index bf877485d62..69401eee3e8 100644 --- a/models/packages/package_file.go +++ b/models/packages/package_file.go @@ -115,6 +115,20 @@ func DeleteFileByID(ctx context.Context, fileID int64) error { return err } +// DeleteFilesByPackageID deletes all files of a specific package +// Versions must not be deleted prior to this call +func DeleteFilesByPackageID(ctx context.Context, packageID int64) error { + deleteStmt := builder.Delete(builder.In("version_id", builder.Select("package_version.id").From("package_version").Where(builder.Eq{"package_id": packageID}))).From("package_file") + _, err := db.GetEngine(ctx).Exec(deleteStmt) + return err +} + +// DeleteFilesByVersionID deletes all files of a specific version +func DeleteFilesByVersionID(ctx context.Context, versionID int64) error { + _, err := db.GetEngine(ctx).Where("version_id = ?", versionID).Delete(&PackageFile{}) + return err +} + func UpdateFile(ctx context.Context, pf *PackageFile, cols []string) error { _, err := db.GetEngine(ctx).ID(pf.ID).Cols(cols...).Update(pf) return err diff --git a/models/packages/package_property.go b/models/packages/package_property.go index acc05d8d5ac..c297fd89014 100644 --- a/models/packages/package_property.go +++ b/models/packages/package_property.go @@ -5,6 +5,7 @@ package packages import ( "context" + "errors" "code.gitea.io/gitea/models/db" @@ -86,6 +87,46 @@ func DeleteAllProperties(ctx context.Context, refType PropertyType, refID int64) return err } +// DeletePropertiesByPackageID deletes properties of a typed linked to the package +// Use to avoid for loops in mass deletion of properties +func DeletePropertiesByPackageID(ctx context.Context, refType PropertyType, packageID int64) error { + var deleteStmt *builder.Builder + + switch refType { + case PropertyTypeFile: + deleteStmt = builder.Delete( + // Delete all properties that are attached to a file and are in ids from a subquery + // which returns ids from the package_file table joined on package_version to link it with package id + builder.Eq{"ref_type": PropertyTypeFile}, builder.In("ref_id", + builder.Select("package_file.id").From("package_file"). + LeftJoin("package_version", "package_file.version_id = package_version.id"). + Where(builder.Eq{"package_version.package_id": packageID}))).From("package_property") + case PropertyTypeVersion: + // Delete all properties that are attached to a version and are in ids from subquery to the package_version filtered by package id + deleteStmt = builder.Delete( + builder.Eq{"ref_type": PropertyTypeVersion}, builder.In("ref_id", + builder.Select("package_version.id").From("package_version"). + Where(builder.Eq{"package_version.package_id": packageID}))).From("package_property") + case PropertyTypePackage: + // Delete all properties that are attached to a package and their reference links to the given package ID + deleteStmt = builder.Delete( + builder.Eq{"ref_type": PropertyTypePackage}, builder.Eq{"ref_id": packageID}). + From("package_property") + default: + return errors.New("invalid ref type") + } + + _, err := db.GetEngine(ctx).Exec(deleteStmt) + return err +} + +// DeleteFilePropertiesByVersionID deletes all file properties linked to specific version +func DeleteFilePropertiesByVersionID(ctx context.Context, versionID int64) error { + deleteStmt := builder.Delete(builder.Eq{"ref_type": PropertyTypeFile}, builder.In("ref_id", builder.Select("id").From("package_file").Where(builder.Eq{"version_id": versionID}))).From("package_property") + _, err := db.GetEngine(ctx).Exec(deleteStmt) + return err +} + // DeletePropertyByID deletes a property func DeletePropertyByID(ctx context.Context, propertyID int64) error { _, err := db.GetEngine(ctx).ID(propertyID).Delete(&PackageProperty{}) diff --git a/models/packages/package_version.go b/models/packages/package_version.go index 0a478c03234..3e0e1899ea4 100644 --- a/models/packages/package_version.go +++ b/models/packages/package_version.go @@ -157,6 +157,12 @@ func DeleteVersionByID(ctx context.Context, versionID int64) error { return err } +// DeleteVersionsByPackageID deletes all versions of a specific package +func DeleteVersionsByPackageID(ctx context.Context, packageID int64) error { + _, err := db.GetEngine(ctx).Where(builder.Eq{"package_id": packageID}).Delete(&PackageVersion{}) + return err +} + // HasVersionFileReferences checks if there are associated files func HasVersionFileReferences(ctx context.Context, versionID int64) (bool, error) { return db.GetEngine(ctx).Get(&PackageFile{ diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 1600b279000..acb5a25087c 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3506,6 +3506,7 @@ "packages.dependencies": "Dependencies", "packages.keywords": "Keywords", "packages.details": "Details", + "packages.name": "Package Name", "packages.details.author": "Author", "packages.details.project_site": "Project Site", "packages.details.repository_site": "Repository Site", @@ -3614,8 +3615,13 @@ "packages.settings.delete": "Delete package", "packages.settings.delete.description": "Deleting a package is permanent and cannot be undone.", "packages.settings.delete.notice": "You are about to delete %s (%s). This operation is irreversible, are you sure?", + "packages.settings.delete.notice.package": "You are about to delete %s and all its versions. This operation is irreversible, are you sure?", "packages.settings.delete.success": "The package has been deleted.", + "packages.settings.delete.version.success": "The package version has been deleted.", "packages.settings.delete.error": "Failed to delete the package.", + "packages.settings.delete.version": "Delete version", + "packages.settings.delete.confirm": "Enter package name to confirm", + "packages.settings.delete.invalid_package_name": "The package name you entered is incorrect.", "packages.owner.settings.cargo.title": "Cargo Registry Index", "packages.owner.settings.cargo.initialize": "Initialize Index", "packages.owner.settings.cargo.initialize.description": "A special index Git repository is needed to use the Cargo registry. Using this option will (re-)create the repository and configure it automatically.", diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index ef38b75696f..c1733095cf9 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1578,10 +1578,11 @@ func Routes() *web.Router { m.Group("/packages/{username}", func() { m.Group("/{type}/{name}", func() { m.Get("/", packages.ListPackageVersions) + m.Delete("", reqPackageAccess(perm.AccessModeWrite), packages.DeletePackage) m.Group("/{version}", func() { m.Get("", packages.GetPackage) - m.Delete("", reqPackageAccess(perm.AccessModeWrite), packages.DeletePackage) + m.Delete("", reqPackageAccess(perm.AccessModeWrite), packages.DeletePackageVersion) m.Get("/files", packages.ListPackageFiles) }) diff --git a/routers/api/v1/packages/package.go b/routers/api/v1/packages/package.go index cee0daccaec..376867ab829 100644 --- a/routers/api/v1/packages/package.go +++ b/routers/api/v1/packages/package.go @@ -118,7 +118,7 @@ func GetPackage(ctx *context.APIContext) { // DeletePackage deletes a package func DeletePackage(ctx *context.APIContext) { - // swagger:operation DELETE /packages/{owner}/{type}/{name}/{version} package deletePackage + // swagger:operation DELETE /packages/{owner}/{type}/{name} package deletePackage // --- // summary: Delete a package // parameters: @@ -137,6 +137,41 @@ func DeletePackage(ctx *context.APIContext) { // description: name of the package // type: string // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + err := packages_service.RemovePackage(ctx, ctx.Doer, ctx.Package.Descriptor.Package) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.Status(http.StatusNoContent) +} + +// DeletePackageVersion deletes a package version +func DeletePackageVersion(ctx *context.APIContext) { + // swagger:operation DELETE /packages/{owner}/{type}/{name}/{version} package deletePackageVersion + // --- + // summary: Delete a package version + // parameters: + // - name: owner + // in: path + // description: owner of the package + // type: string + // required: true + // - name: type + // in: path + // description: type of the package + // type: string + // required: true + // - name: name + // in: path + // description: name of the package + // type: string + // required: true // - name: version // in: path // description: version of the package diff --git a/routers/web/admin/packages.go b/routers/web/admin/packages.go index a0f983914d2..f71a55a2190 100644 --- a/routers/web/admin/packages.go +++ b/routers/web/admin/packages.go @@ -93,7 +93,7 @@ func DeletePackageVersion(ctx *context.Context) { return } - ctx.Flash.Success(ctx.Tr("packages.settings.delete.success")) + ctx.Flash.Success(ctx.Tr("packages.settings.delete.version.success")) ctx.JSONRedirect(setting.AppSubURL + "/-/admin/packages?page=" + url.QueryEscape(ctx.FormString("page")) + "&q=" + url.QueryEscape(ctx.FormString("q")) + "&type=" + url.QueryEscape(ctx.FormString("type"))) } diff --git a/routers/web/user/package.go b/routers/web/user/package.go index ffbfaa229be..b748ead5436 100644 --- a/routers/web/user/package.go +++ b/routers/web/user/package.go @@ -491,18 +491,43 @@ func packageSettingsPostActionLink(ctx *context.Context, form *forms.PackageSett } func packageSettingsPostActionDelete(ctx *context.Context) { - err := packages_service.RemovePackageVersion(ctx, ctx.Doer, ctx.Package.Descriptor.Version) - if err != nil { + pd := ctx.Package.Descriptor + + if ctx.FormString("package_name") != pd.Package.Name { + ctx.Flash.Error(ctx.Tr("packages.settings.delete.invalid_package_name")) + ctx.Redirect(pd.PackageSettingsLink()) + return + } + + if err := packages_service.RemovePackage(ctx, ctx.Doer, pd.Package); err != nil { log.Error("Error deleting package: %v", err) ctx.Flash.Error(ctx.Tr("packages.settings.delete.error")) } else { ctx.Flash.Success(ctx.Tr("packages.settings.delete.success")) } + ctx.Redirect(ctx.Package.Owner.HomeLink() + "/-/packages") +} + +// PackageVersionDelete deletes a package version +func PackageVersionDelete(ctx *context.Context) { + pd := ctx.Package.Descriptor + if pd.Version == nil { + ctx.NotFound(nil) + return + } + + if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pd.Version); err != nil { + log.Error("Error deleting package version: %v", err) + ctx.Flash.Error(ctx.Tr("packages.settings.delete.error")) + } else { + ctx.Flash.Success(ctx.Tr("packages.settings.delete.version.success")) + } + redirectURL := ctx.Package.Owner.HomeLink() + "/-/packages" // redirect to the package if there are still versions available - if has, _ := packages_model.ExistVersion(ctx, &packages_model.PackageSearchOptions{PackageID: ctx.Package.Descriptor.Package.ID, IsInternal: optional.Some(false)}); has { - redirectURL = ctx.Package.Descriptor.PackageWebLink() + if has, _ := packages_model.ExistVersion(ctx, &packages_model.PackageSearchOptions{PackageID: pd.Package.ID, IsInternal: optional.Some(false)}); has { + redirectURL = pd.PackageWebLink() } ctx.Redirect(redirectURL) @@ -512,7 +537,7 @@ func packageSettingsPostActionDelete(ctx *context.Context) { func DownloadPackageFile(ctx *context.Context) { pf, err := packages_model.GetFileForVersionByID(ctx, ctx.Package.Descriptor.Version.ID, ctx.PathParamInt64("fileid")) if err != nil { - if err == packages_model.ErrPackageFileNotExist { + if errors.Is(err, packages_model.ErrPackageFileNotExist) { ctx.NotFound(err) } else { ctx.ServerError("GetFileForVersionByID", err) diff --git a/routers/web/web.go b/routers/web/web.go index 86397bb9394..f85c2f75011 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1071,14 +1071,15 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Get("/versions", user.ListPackageVersions) m.Group("/{version}", func() { m.Get("", user.ViewPackageVersion) + m.Post("", reqPackageAccess(perm.AccessModeWrite), user.PackageVersionDelete) m.Get("/{version_sub}", user.ViewPackageVersion) m.Get("/files/{fileid}", user.DownloadPackageFile) - m.Group("/settings", func() { - m.Get("", user.PackageSettings) - m.Post("", web.Bind(forms.PackageSettingForm{}), user.PackageSettingsPost) - }, reqPackageAccess(perm.AccessModeWrite)) }) }) + m.Group("/settings/{type}/{name}", func() { + m.Get("", user.PackageSettings) + m.Post("", web.Bind(forms.PackageSettingForm{}), user.PackageSettingsPost) + }, reqPackageAccess(perm.AccessModeWrite)) }, context.PackageAssignment(), reqPackageAccess(perm.AccessModeRead)) } diff --git a/services/context/package.go b/services/context/package.go index 0e9210515bb..6ed9de43678 100644 --- a/services/context/package.go +++ b/services/context/package.go @@ -4,6 +4,7 @@ package context import ( + "errors" "fmt" "net/http" @@ -58,23 +59,28 @@ func PackageAssignmentAPI() func(ctx *APIContext) { } func packageAssignment(ctx *packageAssignmentCtx, errCb func(int, any)) *Package { - pkg := &Package{ - Owner: ctx.ContextUser, - } - var err error - pkg.AccessMode, err = determineAccessMode(ctx.Base, pkg, ctx.Doer) + pkgOwner := ctx.ContextUser + accessMode, err := determineAccessMode(ctx.Base, pkgOwner, ctx.Doer) if err != nil { errCb(http.StatusInternalServerError, fmt.Errorf("determineAccessMode: %w", err)) + return nil + } + + pkg := &Package{ + Owner: pkgOwner, + AccessMode: accessMode, + } + packageType := ctx.PathParam("type") + name := ctx.PathParam("name") + if packageType == "" || name == "" { return pkg } - packageType := ctx.PathParam("type") - name := ctx.PathParam("name") version := ctx.PathParam("version") - if packageType != "" && name != "" && version != "" { + if version != "" { pv, err := packages_model.GetVersionByNameAndVersion(ctx, pkg.Owner.ID, packages_model.Type(packageType), name, version) if err != nil { - if err == packages_model.ErrPackageNotExist { + if errors.Is(err, packages_model.ErrPackageNotExist) { errCb(http.StatusNotFound, fmt.Errorf("GetVersionByNameAndVersion: %w", err)) } else { errCb(http.StatusInternalServerError, fmt.Errorf("GetVersionByNameAndVersion: %w", err)) @@ -87,12 +93,27 @@ func packageAssignment(ctx *packageAssignmentCtx, errCb func(int, any)) *Package errCb(http.StatusInternalServerError, fmt.Errorf("GetPackageDescriptor: %w", err)) return pkg } + } else { + p, err := packages_model.GetPackageByName(ctx, pkg.Owner.ID, packages_model.Type(packageType), name) + if err != nil { + if errors.Is(err, packages_model.ErrPackageNotExist) { + errCb(http.StatusNotFound, fmt.Errorf("GetPackageByName: %w", err)) + } else { + errCb(http.StatusInternalServerError, fmt.Errorf("GetPackageByName: %w", err)) + } + return pkg + } + + pkg.Descriptor = &packages_model.PackageDescriptor{ + Package: p, + Owner: pkg.Owner, + } } return pkg } -func determineAccessMode(ctx *Base, pkg *Package, doer *user_model.User) (perm.AccessMode, error) { +func determineAccessMode(ctx *Base, pkgOwner, doer *user_model.User) (perm.AccessMode, error) { if setting.Service.RequireSignInViewStrict && (doer == nil || doer.IsGhost()) { return perm.AccessModeNone, nil } @@ -103,8 +124,8 @@ func determineAccessMode(ctx *Base, pkg *Package, doer *user_model.User) (perm.A // TODO: ActionUser permission check accessMode := perm.AccessModeNone - if pkg.Owner.IsOrganization() { - org := organization.OrgFromUser(pkg.Owner) + if pkgOwner.IsOrganization() { + org := organization.OrgFromUser(pkgOwner) if doer != nil && !doer.IsGhost() { // 1. If user is logged in, check all team packages permissions @@ -128,19 +149,19 @@ func determineAccessMode(ctx *Base, pkg *Package, doer *user_model.User) (perm.A } } } - if accessMode == perm.AccessModeNone && organization.HasOrgOrUserVisible(ctx, pkg.Owner, doer) { + if accessMode == perm.AccessModeNone && organization.HasOrgOrUserVisible(ctx, pkgOwner, doer) { // 2. If user is unauthorized or no org member, check if org is visible accessMode = perm.AccessModeRead } } else { if doer != nil && !doer.IsGhost() { // 1. Check if user is package owner - if doer.ID == pkg.Owner.ID { + if doer.ID == pkgOwner.ID { accessMode = perm.AccessModeOwner - } else if pkg.Owner.Visibility == structs.VisibleTypePublic || pkg.Owner.Visibility == structs.VisibleTypeLimited { // 2. Check if package owner is public or limited + } else if pkgOwner.Visibility == structs.VisibleTypePublic || pkgOwner.Visibility == structs.VisibleTypeLimited { // 2. Check if package owner is public or limited accessMode = perm.AccessModeRead } - } else if pkg.Owner.Visibility == structs.VisibleTypePublic { // 3. Check if package owner is public + } else if pkgOwner.Visibility == structs.VisibleTypePublic { // 3. Check if package owner is public accessMode = perm.AccessModeRead } } diff --git a/services/packages/cleanup/cleanup.go b/services/packages/cleanup/cleanup.go index f2b5bce4f6f..5cff16fe402 100644 --- a/services/packages/cleanup/cleanup.go +++ b/services/packages/cleanup/cleanup.go @@ -184,6 +184,7 @@ func CleanupExpiredData(ctx context.Context, olderThan time.Duration) error { } } + // HINT: PACKAGE-DEFER-STORAGE-DELETE: Handle blob deletion for package storage pbs, err = packages_model.FindExpiredUnreferencedBlobs(ctx, olderThan) if err != nil { return err diff --git a/services/packages/packages.go b/services/packages/packages.go index 3b4e11e0410..47714add82c 100644 --- a/services/packages/packages.go +++ b/services/packages/packages.go @@ -473,7 +473,8 @@ func RemovePackageVersion(ctx context.Context, doer *user_model.User, pv *packag if err != nil { return err } - + // HINT: PACKAGE-DEFER-STORAGE-DELETE: Blobs are not deleted immediately, instead they are deleted by the cleanup_packages cron task. + // If there are no more versions for the package, the same task removes that as well. if err := db.WithTx(ctx, func(ctx context.Context) error { log.Trace("Deleting package: %v", pv.ID) return DeletePackageVersionAndReferences(ctx, pv) @@ -532,16 +533,11 @@ func DeletePackageVersionAndReferences(ctx context.Context, pv *packages_model.P if err := packages_model.DeleteAllProperties(ctx, packages_model.PropertyTypeVersion, pv.ID); err != nil { return err } - - pfs, err := packages_model.GetFilesByVersionID(ctx, pv.ID) - if err != nil { + if err := packages_model.DeleteFilePropertiesByVersionID(ctx, pv.ID); err != nil { return err } - - for _, pf := range pfs { - if err := DeletePackageFile(ctx, pf); err != nil { - return err - } + if err := packages_model.DeleteFilesByVersionID(ctx, pv.ID); err != nil { + return err } return packages_model.DeleteVersionByID(ctx, pv.ID) @@ -629,6 +625,46 @@ func OpenBlobForDownload(ctx context.Context, pf *packages_model.PackageFile, pb return s, u, pf, nil } +// RemovePackage deletes the package and all its versions +func RemovePackage(ctx context.Context, doer *user_model.User, p *packages_model.Package) error { + pds, err := packages_model.GetAllPackageDescriptors(ctx, p) + if err != nil { + return err + } + // HINT: PACKAGE-DEFER-STORAGE-DELETE: Blobs are not deleted immediately, instead they are deleted by cleanup_packages cron task. + err = db.WithTx(ctx, func(ctx context.Context) error { + err := packages_model.DeletePropertiesByPackageID(ctx, packages_model.PropertyTypePackage, p.ID) + if err != nil { + return err + } + err = packages_model.DeletePropertiesByPackageID(ctx, packages_model.PropertyTypeFile, p.ID) + if err != nil { + return err + } + err = packages_model.DeletePropertiesByPackageID(ctx, packages_model.PropertyTypeVersion, p.ID) + if err != nil { + return err + } + err = packages_model.DeleteFilesByPackageID(ctx, p.ID) + if err != nil { + return err + } + err = packages_model.DeleteVersionsByPackageID(ctx, p.ID) + if err != nil { + return err + } + + return packages_model.DeletePackageByID(ctx, p.ID) + }) + if err != nil { + return err + } + for _, pd := range pds { + notify_service.PackageDelete(ctx, doer, pd) + } + return nil +} + // RemoveAllPackages for User func RemoveAllPackages(ctx context.Context, userID int64) (int, error) { count := 0 diff --git a/templates/admin/packages/list.tmpl b/templates/admin/packages/list.tmpl index 395c63053ba..8701cb0d57d 100644 --- a/templates/admin/packages/list.tmpl +++ b/templates/admin/packages/list.tmpl @@ -89,7 +89,7 @@
diff --git a/templates/package/shared/view.tmpl b/templates/package/shared/view.tmpl index cfdc114e1bb..7c7b5b16dd2 100644 --- a/templates/package/shared/view.tmpl +++ b/templates/package/shared/view.tmpl @@ -99,7 +99,24 @@
{{svg "octicon-issue-opened"}} {{ctx.Locale.Tr "repo.issues"}}
{{end}} {{if .CanWritePackages}} -
{{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}}
+
{{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}}
+
+ {{svg "octicon-trash"}} + {{ctx.Locale.Tr "packages.settings.delete.version"}} + +
{{end}} {{end}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index b4247e26124..d5e258cb567 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -3912,6 +3912,44 @@ "$ref": "#/responses/notFound" } } + }, + "delete": { + "tags": [ + "package" + ], + "summary": "Delete a package", + "operationId": "deletePackage", + "parameters": [ + { + "type": "string", + "description": "owner of the package", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "type of the package", + "name": "type", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the package", + "name": "name", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "404": { + "$ref": "#/responses/notFound" + } + } } }, "/packages/{owner}/{type}/{name}/-/latest": { @@ -4097,8 +4135,8 @@ "tags": [ "package" ], - "summary": "Delete a package", - "operationId": "deletePackage", + "summary": "Delete a package version", + "operationId": "deletePackageVersion", "parameters": [ { "type": "string", diff --git a/tests/integration/api_packages_test.go b/tests/integration/api_packages_test.go index 4fbb31b11ff..913ce1bae21 100644 --- a/tests/integration/api_packages_test.go +++ b/tests/integration/api_packages_test.go @@ -85,6 +85,50 @@ func TestPackageAPI(t *testing.T) { assert.Equal(t, user.Name, p.Creator.UserName) }) + t.Run("DeleteEntirePackage", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + packageName := "test-package-entire-delete" + for _, version := range []string{"1.0.1", "1.0.2"} { + url := fmt.Sprintf("/api/packages/%s/generic/%s/%s/file.bin", user.Name, packageName, version) + req := NewRequestWithBody(t, "PUT", url, bytes.NewReader([]byte{1})). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + } + + req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/packages/%s/generic/%s", user.Name, packageName)). + AddTokenAuth(tokenWritePackage) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/generic/%s", user.Name, packageName)). + AddTokenAuth(tokenReadPackage) + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("DeletePackageVersion", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + packageName := "test-package-version-delete" + for _, version := range []string{"1.0.1", "1.0.2"} { + url := fmt.Sprintf("/api/packages/%s/generic/%s/%s/file.bin", user.Name, packageName, version) + req := NewRequestWithBody(t, "PUT", url, bytes.NewReader([]byte{1})). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + } + + req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/packages/%s/generic/%s/1.0.1", user.Name, packageName)). + AddTokenAuth(tokenWritePackage) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/generic/%s/1.0.1", user.Name, packageName)). + AddTokenAuth(tokenReadPackage) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/generic/%s/1.0.2", user.Name, packageName)). + AddTokenAuth(tokenReadPackage) + MakeRequest(t, req, http.StatusOK) + }) + t.Run("ListPackageVersions", func(t *testing.T) { defer tests.PrintCurrentTest(t)() diff --git a/tests/integration/packages_service_test.go b/tests/integration/packages_service_test.go new file mode 100644 index 00000000000..361950cb107 --- /dev/null +++ b/tests/integration/packages_service_test.go @@ -0,0 +1,118 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "testing" + + packages_model "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + packages_module "code.gitea.io/gitea/modules/packages" + packages_service "code.gitea.io/gitea/services/packages" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRemovePackage(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // 1. Setup: Create two packages with properties at all levels + createPackage := func(name string) (*packages_model.Package, *packages_model.PackageVersion, *packages_model.PackageFile) { + data, _ := packages_module.CreateHashedBufferFromReader(bytes.NewReader([]byte{1})) + pv, pf, err := packages_service.CreatePackageOrAddFileToExisting(t.Context(), &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: user, + PackageType: packages_model.TypeGeneric, + Name: name, + Version: "1.0.0", + }, + Creator: user, + PackageProperties: map[string]string{"pkg_prop": "val"}, + VersionProperties: map[string]string{"ver_prop": "val"}, + }, &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{Filename: "file.bin"}, + Creator: user, + Data: data, + Properties: map[string]string{"file_prop": "val"}, + }) + require.NoError(t, err) + + p, err := packages_model.GetPackageByID(t.Context(), pv.PackageID) + require.NoError(t, err) + + return p, pv, pf + } + + p1, pv1, pf1 := createPackage("package-1") + p2, pv2, pf2 := createPackage("package-2") + + // Verify properties exist before deletion + checkProps := func(p *packages_model.Package, pv *packages_model.PackageVersion, pf *packages_model.PackageFile, shouldExist bool) { + pps, err := packages_model.GetProperties(t.Context(), packages_model.PropertyTypePackage, p.ID) + require.NoError(t, err) + if shouldExist { + assert.NotEmpty(t, pps) + } else { + assert.Empty(t, pps) + } + + pps, err = packages_model.GetProperties(t.Context(), packages_model.PropertyTypeVersion, pv.ID) + require.NoError(t, err) + if shouldExist { + assert.NotEmpty(t, pps) + } else { + assert.Empty(t, pps) + } + + pps, err = packages_model.GetProperties(t.Context(), packages_model.PropertyTypeFile, pf.ID) + require.NoError(t, err) + if shouldExist { + assert.NotEmpty(t, pps) + } else { + assert.Empty(t, pps) + } + } + + checkProps(p1, pv1, pf1, true) + checkProps(p2, pv2, pf2, true) + + // 2. Act: Remove package 1 + err := packages_service.RemovePackage(t.Context(), user, p1) + assert.NoError(t, err) + + // 3. Assert: Package 1 is gone, Package 2 is untouched + + // Check P1 + _, err = packages_model.GetPackageByID(t.Context(), p1.ID) + assert.ErrorIs(t, err, packages_model.ErrPackageNotExist) + + _, err = packages_model.GetVersionByID(t.Context(), pv1.ID) + assert.ErrorIs(t, err, packages_model.ErrPackageNotExist) + + _, err = packages_model.GetFileForVersionByID(t.Context(), pv1.ID, pf1.ID) + assert.ErrorIs(t, err, packages_model.ErrPackageFileNotExist) + + checkProps(p1, pv1, pf1, false) + + // Check P2 + p2After, err := packages_model.GetPackageByID(t.Context(), p2.ID) + assert.NoError(t, err) + assert.NotNil(t, p2After) + + pv2After, err := packages_model.GetVersionByID(t.Context(), pv2.ID) + assert.NoError(t, err) + assert.NotNil(t, pv2After) + + pf2After, err := packages_model.GetFileForVersionByID(t.Context(), pv2.ID, pf2.ID) + assert.NoError(t, err) + assert.NotNil(t, pf2After) + + checkProps(p2, pv2, pf2, true) +} From e47c6135dd21462a6c48e0dc8bdd6a4717d1f67c Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sun, 5 Apr 2026 13:37:35 -0700 Subject: [PATCH 4/4] Add gpg signing for merge rebase and update by rebase (#36701) Fix #36685 --- Generated by a coding agent with Codex 5.2 LLM. --- services/pull/merge.go | 20 +++++++----- services/pull/merge_prepare.go | 4 ++- services/pull/merge_rebase.go | 8 ++--- services/pull/merge_squash.go | 9 +---- tests/integration/gpg_ssh_git_test.go | 47 +++++++++++++++++++++++++++ 5 files changed, 67 insertions(+), 21 deletions(-) diff --git a/services/pull/merge.go b/services/pull/merge.go index 4925302797b..1137296cae9 100644 --- a/services/pull/merge.go +++ b/services/pull/merge.go @@ -436,20 +436,24 @@ func doMergeAndPush(ctx context.Context, pr *issues_model.PullRequest, doer *use func commitAndSignNoAuthor(ctx *mergeContext, message string) error { cmdCommit := gitcmd.NewCommand("commit").AddOptionFormat("--message=%s", message) - if ctx.signKey == nil { - cmdCommit.AddArguments("--no-gpg-sign") - } else { - if ctx.signKey.Format != "" { - cmdCommit.AddConfig("gpg.format", ctx.signKey.Format) - } - cmdCommit.AddOptionFormat("-S%s", ctx.signKey.KeyID) - } + addCommitSigningOptions(cmdCommit, ctx.signKey) if err := ctx.PrepareGitCmd(cmdCommit).RunWithStderr(ctx); err != nil { return fmt.Errorf("git commit %v: %w\n%s", ctx.pr, err, ctx.outbuf.String()) } return nil } +func addCommitSigningOptions(cmd *gitcmd.Command, signKey *git.SigningKey) { + if signKey == nil { + cmd.AddArguments("--no-gpg-sign") + return + } + if signKey.Format != "" { + cmd.AddConfig("gpg.format", signKey.Format) + } + cmd.AddOptionFormat("--gpg-sign=%s", signKey.KeyID) +} + // ErrMergeConflicts represents an error if merging fails with a conflict type ErrMergeConflicts struct { Style repo_model.MergeStyle diff --git a/services/pull/merge_prepare.go b/services/pull/merge_prepare.go index 1131a23d851..c23bfdc7f6a 100644 --- a/services/pull/merge_prepare.go +++ b/services/pull/merge_prepare.go @@ -260,7 +260,9 @@ func rebaseTrackingOnToBase(ctx *mergeContext, mergeStyle repo_model.MergeStyle) ctx.outbuf.Reset() // Rebase before merging - if err := ctx.PrepareGitCmd(gitcmd.NewCommand("rebase").AddDynamicArguments(tmpRepoBaseBranch)). + cmdRebase := gitcmd.NewCommand("rebase").AddDynamicArguments(tmpRepoBaseBranch) + addCommitSigningOptions(cmdRebase, ctx.signKey) + if err := ctx.PrepareGitCmd(cmdRebase). RunWithStderr(ctx); err != nil { // Rebase will leave a REBASE_HEAD file in .git if there is a conflict if _, statErr := os.Stat(filepath.Join(ctx.tmpBasePath, ".git", "REBASE_HEAD")); statErr == nil { diff --git a/services/pull/merge_rebase.go b/services/pull/merge_rebase.go index 9dbe67a6c6a..0224f7f1bd3 100644 --- a/services/pull/merge_rebase.go +++ b/services/pull/merge_rebase.go @@ -74,10 +74,10 @@ func doMergeRebaseFastForward(ctx *mergeContext) error { } if newMessage != "" { - if err := gitcmd.NewCommand("commit", "--amend"). - AddOptionFormat("--message=%s", newMessage). - WithDir(ctx.tmpBasePath). - Run(ctx); err != nil { + cmdCommit := gitcmd.NewCommand("commit", "--amend"). + AddOptionFormat("--message=%s", newMessage) + addCommitSigningOptions(cmdCommit, ctx.signKey) + if err := cmdCommit.WithDir(ctx.tmpBasePath).Run(ctx); err != nil { log.Error("Unable to amend commit message: %v", err) return err } diff --git a/services/pull/merge_squash.go b/services/pull/merge_squash.go index 6c101c8e89a..0f716f4d771 100644 --- a/services/pull/merge_squash.go +++ b/services/pull/merge_squash.go @@ -73,14 +73,7 @@ func doMergeStyleSquash(ctx *mergeContext, message string) error { AddOptionFormat("--author='%s <%s>'", sig.Name, sig.Email). AddOptionFormat("--message=%s", message). AddArguments("--allow-empty") - if ctx.signKey == nil { - cmdCommit.AddArguments("--no-gpg-sign") - } else { - if ctx.signKey.Format != "" { - cmdCommit.AddConfig("gpg.format", ctx.signKey.Format) - } - cmdCommit.AddOptionFormat("-S%s", ctx.signKey.KeyID) - } + addCommitSigningOptions(cmdCommit, ctx.signKey) if err := ctx.PrepareGitCmd(cmdCommit).RunWithStderr(ctx); err != nil { log.Error("git commit %-v: %v\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), err.Stderr()) return fmt.Errorf("git commit [%s:%s -> %s:%s]: %w\n%s\n%s", ctx.pr.HeadRepo.FullName(), ctx.pr.HeadBranch, ctx.pr.BaseRepo.FullName(), ctx.pr.BaseBranch, err, ctx.outbuf.String(), err.Stderr()) diff --git a/tests/integration/gpg_ssh_git_test.go b/tests/integration/gpg_ssh_git_test.go index 56f9f877833..a95ff77dab4 100644 --- a/tests/integration/gpg_ssh_git_test.go +++ b/tests/integration/gpg_ssh_git_test.go @@ -9,6 +9,7 @@ import ( "encoding/base64" "encoding/pem" "fmt" + "net/http" "net/url" "os" "testing" @@ -255,6 +256,52 @@ func testGitSigning(t *testing.T) { assert.True(t, branch.Commit.Verification.Verified) })) }) + + setting.Repository.Signing.Merges = []string{"commitssigned"} + setting.Repository.Signing.CRUDActions = []string{"always"} + t.Run("UpdateRebaseSigned", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + testCtx := NewAPITestContext(t, username, "update-rebase-signed", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + t.Run("CreateRepository", doAPICreateRepository(testCtx, false)) + + var repoID int64 + t.Run("GetRepository", doAPIGetRepository(testCtx, func(t *testing.T, repo api.Repository) { + repoID = repo.ID + })) + enableRepoAllowUpdateWithRebase(t, repoID, true) + + t.Run("CreateFeatureCommit", crudActionCreateFile( + t, testCtx, user, "master", "feature", "signed-feature.txt")) + pr, err := doAPICreatePullRequest(testCtx, testCtx.Username, testCtx.Reponame, "master", "feature")(t) + require.NoError(t, err) + + content := base64.StdEncoding.EncodeToString([]byte("update base")) + t.Run("UpdateBase", doAPICreateFile(testCtx, "signed-base.txt", &api.CreateFileOptions{ + FileOptions: api.FileOptions{ + BranchName: "master", + Message: "update base", + Author: api.Identity{ + Name: user.FullName, + Email: user.Email, + }, + Committer: api.Identity{ + Name: user.FullName, + Email: user.Email, + }, + }, + ContentBase64: content, + })) + + req := NewRequestf(t, "POST", "/api/v1/repos/%s/%s/pulls/%d/update?style=rebase", testCtx.Username, testCtx.Reponame, pr.Index). + AddTokenAuth(testCtx.Token) + testCtx.Session.MakeRequest(t, req, http.StatusOK) + + t.Run("CheckFeatureBranchSigned", doAPIGetBranch(testCtx, "feature", func(t *testing.T, branch api.Branch) { + require.NotNil(t, branch.Commit) + require.NotNil(t, branch.Commit.Verification) + assert.True(t, branch.Commit.Verification.Verified) + })) + }) }) }