diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index f78c4e6c51..16afa21771 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -47,6 +47,22 @@ jobs: sudo chmod +x /usr/local/bin/gitea sudo chown gitea:gitea /usr/local/bin/gitea || true + # Ensure [html_preview] config exists + if ! sudo grep -q '\[html_preview\]' /etc/gitea/app.ini; then + echo "Adding [html_preview] config..." + sudo tee -a /etc/gitea/app.ini > /dev/null <<'APPINI' + +[html_preview] +ENABLED = true +STORAGE_TYPE = minio +MINIO_ENDPOINT = s3.eu-west-1.amazonaws.com +MINIO_BUCKET = tinqs-git-preview +MINIO_LOCATION = eu-west-1 +MINIO_USE_SSL = true +SERVE_DIRECT = true +APPINI + fi + if [ "$RESTART" = "true" ]; then echo "Starting gitea..." sudo systemctl start gitea diff --git a/modules/setting/preview.go b/modules/setting/preview.go new file mode 100644 index 0000000000..be4cf6c9db --- /dev/null +++ b/modules/setting/preview.go @@ -0,0 +1,29 @@ +// Copyright 2026 The Tinqs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +// HTMLPreview represents the configuration for HTML file preview via S3. +// Configured via [html_preview] section in app.ini. +var HTMLPreview = struct { + Enabled bool + + Storage *Storage +}{} + +func loadHTMLPreviewFrom(rootCfg ConfigProvider) error { + sec, _ := rootCfg.GetSection("html_preview") + if sec == nil { + HTMLPreview.Enabled = false + return nil + } + + HTMLPreview.Enabled = sec.Key("ENABLED").MustBool(false) + if !HTMLPreview.Enabled { + return nil + } + + var err error + HTMLPreview.Storage, err = getStorage(rootCfg, "html_preview", "", sec) + return err +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 3c1ad14428..b4017fce40 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -147,6 +147,9 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error { if err := loadActionsFrom(cfg); err != nil { return err } + if err := loadHTMLPreviewFrom(cfg); err != nil { + return err + } loadUIFrom(cfg) loadAdminFrom(cfg) loadAPIFrom(cfg) diff --git a/modules/storage/storage.go b/modules/storage/storage.go index e19c421ba8..6c54639296 100644 --- a/modules/storage/storage.go +++ b/modules/storage/storage.go @@ -180,6 +180,9 @@ var ( Actions ObjectStorage = uninitializedStorage // ActionsArtifacts Artifacts represents actions artifacts storage ActionsArtifacts ObjectStorage = uninitializedStorage + + // HTMLPreview represents HTML preview storage (S3 bucket for serving HTML files) + HTMLPreview ObjectStorage = uninitializedStorage ) // Init init the storage @@ -192,6 +195,7 @@ func Init() error { initRepoArchives, initPackages, initActions, + initHTMLPreview, } { if err := f(); err != nil { return err @@ -275,3 +279,13 @@ func initActions() (err error) { ActionsArtifacts, err = NewStorage(setting.Actions.ArtifactStorage.Type, setting.Actions.ArtifactStorage) return err } + +func initHTMLPreview() (err error) { + if !setting.HTMLPreview.Enabled { + HTMLPreview = discardStorage("HTMLPreview isn't enabled") + return nil + } + log.Info("Initialising HTMLPreview storage with type: %s", setting.HTMLPreview.Storage.Type) + HTMLPreview, err = NewStorage(setting.HTMLPreview.Storage.Type, setting.HTMLPreview.Storage) + return err +} diff --git a/routers/web/repo/preview.go b/routers/web/repo/preview.go new file mode 100644 index 0000000000..dd77f40f2b --- /dev/null +++ b/routers/web/repo/preview.go @@ -0,0 +1,197 @@ +// Copyright 2026 The Tinqs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "fmt" + "io" + "net/http" + "os" + "sync" + + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" +) + +// previewCache tracks known blob SHAs on S3 to skip Stat calls. +// Key: S3 object path, Value: blob SHA that was uploaded. +var previewCache sync.Map + +// HTMLPreview serves an HTML file via S3 with stale-while-revalidate caching. +// +// Flow: +// 1. Resolve the git blob for the requested file path +// 2. Build the S3 object key: preview/{owner}/{repo}/{path} +// 3. Check in-memory cache for the blob SHA +// 4. HIT+fresh (SHA matches current blob) → redirect to S3 presigned URL +// 5. HIT+stale (SHA mismatch) → redirect to S3 immediately, revalidate async +// 6. MISS → upload synchronously, then redirect +func HTMLPreview(ctx *context.Context) { + if !setting.HTMLPreview.Enabled { + ctx.NotFound(fmt.Errorf("HTML preview is not enabled")) + return + } + + treePath := ctx.Repo.TreePath + if !isHTMLTreePath(treePath) { + ctx.NotFound(fmt.Errorf("not an HTML file")) + return + } + + // Get the blob + entry, err := ctx.Repo.Commit.GetTreeEntryByPath(treePath) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound(err) + } else { + ctx.ServerError("GetTreeEntryByPath", err) + } + return + } + if entry.IsDir() || entry.IsSubModule() { + ctx.NotFound(nil) + return + } + + blob := entry.Blob() + blobSHA := blob.ID.String() + s3Key := buildPreviewS3Key(ctx) + + // Check in-memory cache + if cachedSHA, ok := previewCache.Load(s3Key); ok { + if cachedSHA.(string) == blobSHA { + // Cache HIT + fresh — redirect directly + redirectToS3(ctx, s3Key, treePath) + return + } + // Cache HIT + stale — redirect to old version, revalidate in background + go revalidatePreview(s3Key, blob, blobSHA) + redirectToS3(ctx, s3Key, treePath) + return + } + + // Cache MISS — check if S3 has it (cold start) + _, err = storage.HTMLPreview.Stat(s3Key) + if err == nil { + // S3 has a version — serve it and revalidate in background + previewCache.Store(s3Key, "") // mark as known but unknown SHA + go revalidatePreview(s3Key, blob, blobSHA) + redirectToS3(ctx, s3Key, treePath) + return + } + + // No cached version — upload synchronously + if err := uploadPreview(s3Key, blob, blobSHA); err != nil { + ctx.ServerError("uploadPreview", err) + return + } + redirectToS3(ctx, s3Key, treePath) +} + +// HTMLPreviewByID serves an HTML preview by blob SHA (for diff views). +func HTMLPreviewByID(ctx *context.Context) { + if !setting.HTMLPreview.Enabled { + ctx.NotFound(fmt.Errorf("HTML preview is not enabled")) + return + } + + blob, err := ctx.Repo.GitRepo.GetBlob(ctx.PathParam("sha")) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound(nil) + } else { + ctx.ServerError("GetBlob", err) + } + return + } + + blobSHA := blob.ID.String() + // For blob-by-SHA, use a content-addressed key (immutable) + s3Key := fmt.Sprintf("blobs/%s", blobSHA) + + _, err = storage.HTMLPreview.Stat(s3Key) + if err == nil { + redirectToS3(ctx, s3Key, blobSHA+".html") + return + } + + if err := uploadPreview(s3Key, blob, blobSHA); err != nil { + ctx.ServerError("uploadPreview", err) + return + } + redirectToS3(ctx, s3Key, blobSHA+".html") +} + +func buildPreviewS3Key(ctx *context.Context) string { + owner := ctx.Repo.Repository.OwnerName + repo := ctx.Repo.Repository.Name + ref := ctx.Repo.RefFullName.ShortName() + path := util.PathEscapeSegments(ctx.Repo.TreePath) + return fmt.Sprintf("%s/%s/%s/%s", owner, repo, ref, path) +} + +func redirectToS3(ctx *context.Context, s3Key, name string) { + u, err := storage.HTMLPreview.ServeDirectURL(s3Key, name, http.MethodGet, &storage.ServeDirectOptions{ + ContentType: "text/html; charset=utf-8", + }) + if err != nil { + // Fallback: serve directly through Gitea + servePreviewDirect(ctx, s3Key) + return + } + ctx.Redirect(u.String()) +} + +func servePreviewDirect(ctx *context.Context, s3Key string) { + obj, err := storage.HTMLPreview.Open(s3Key) + if err != nil { + if os.IsNotExist(err) { + ctx.NotFound(err) + } else { + ctx.ServerError("Open preview", err) + } + return + } + defer obj.Close() + + info, err := obj.Stat() + if err != nil { + ctx.ServerError("Stat preview", err) + return + } + + ctx.Resp.Header().Set("Content-Type", "text/html; charset=utf-8") + ctx.Resp.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size())) + ctx.Resp.Header().Set("X-Content-Type-Options", "nosniff") + _, _ = io.Copy(ctx.Resp, obj) +} + +func uploadPreview(s3Key string, blob *git.Blob, blobSHA string) error { + dataRc, err := blob.DataAsync() + if err != nil { + return fmt.Errorf("blob DataAsync: %w", err) + } + defer dataRc.Close() + + _, err = storage.HTMLPreview.Save(s3Key, dataRc, blob.Size()) + if err != nil { + return fmt.Errorf("S3 save: %w", err) + } + + previewCache.Store(s3Key, blobSHA) + log.Debug("HTMLPreview: uploaded %s (blob %s)", s3Key, blobSHA[:8]) + return nil +} + +func revalidatePreview(s3Key string, blob *git.Blob, blobSHA string) { + if err := uploadPreview(s3Key, blob, blobSHA); err != nil { + log.Error("HTMLPreview revalidate failed for %s: %v", s3Key, err) + } +} + +// isHTMLTreePath is defined in view_file.go diff --git a/routers/web/repo/view_file.go b/routers/web/repo/view_file.go index e3e468a79d..287b9ce165 100644 --- a/routers/web/repo/view_file.go +++ b/routers/web/repo/view_file.go @@ -192,6 +192,8 @@ func prepareFileView(ctx *context.Context, entry *git.TreeEntry) { ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/raw/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) if isHTMLTreePath(ctx.Repo.TreePath) { ctx.Data["IsHTMLFile"] = true + ctx.Data["HTMLPreviewEnabled"] = setting.HTMLPreview.Enabled + ctx.Data["HTMLPreviewLink"] = ctx.Repo.RepoLink + "/preview/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) } if ctx.Repo.TreePath == ".editorconfig" { diff --git a/routers/web/web.go b/routers/web/web.go index 61d1fdc142..ecee32013f 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1664,6 +1664,14 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Get("/*", context.RepoRefByType(""), repo.SingleDownload) // "/*" route is deprecated, and kept for backward compatibility }, webAuth.AllowBasic, webAuth.AllowOAuth2, repo.MustBeNotEmpty) + m.Group("/preview", func() { + m.Get("/blob/{sha}", repo.HTMLPreviewByID) + m.Get("/branch/*", context.RepoRefByType(git.RefTypeBranch), repo.HTMLPreview) + m.Get("/tag/*", context.RepoRefByType(git.RefTypeTag), repo.HTMLPreview) + m.Get("/commit/*", context.RepoRefByType(git.RefTypeCommit), repo.HTMLPreview) + m.Get("/*", context.RepoRefByType(""), repo.HTMLPreview) + }, webAuth.AllowBasic, webAuth.AllowOAuth2, repo.MustBeNotEmpty) + m.Group("/render", func() { m.Get("/branch/*", context.RepoRefByType(git.RefTypeBranch), repo.RenderFile) m.Get("/tag/*", context.RepoRefByType(git.RefTypeTag), repo.RenderFile) diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index 266288f306..01ad8fa9af 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -136,7 +136,11 @@ {{ctx.Locale.Tr "repo.audio_not_supported_in_browser"}} {{else if .IsHTMLFile}} - + {{if .HTMLPreviewEnabled}} + + {{else}} + + {{end}} {{else}}