feat: HTML preview via S3 with SWR caching
HTML files now render in an iframe served from S3 (tinqs-git-preview bucket) instead of Gitea's raw endpoint which forces text/plain. SWR flow: first request uploads blob to S3 synchronously, subsequent requests redirect to presigned S3 URL instantly. When the blob SHA changes (new commit), the stale version is served immediately while the new version uploads in the background. Security: iframe uses sandbox="allow-scripts" only (no allow-same-origin). S3 is a different origin from git.arikigame.com, so even if JS runs in the iframe it cannot access Gitea session cookies or API tokens. Config: [html_preview] section in app.ini, disabled by default. Release pipeline auto-adds config on first deploy. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -136,7 +136,11 @@
|
||||
<strong>{{ctx.Locale.Tr "repo.audio_not_supported_in_browser"}}</strong>
|
||||
</audio>
|
||||
{{else if .IsHTMLFile}}
|
||||
<iframe src="{{$.RawFileLink}}" class="html-preview-iframe" style="width:100%;min-height:600px;border:none;" sandbox="allow-scripts allow-same-origin"></iframe>
|
||||
{{if .HTMLPreviewEnabled}}
|
||||
<iframe src="{{$.HTMLPreviewLink}}" class="html-preview-iframe" style="width:100%;min-height:600px;border:none;" sandbox="allow-scripts"></iframe>
|
||||
{{else}}
|
||||
<iframe src="{{$.RawFileLink}}" class="html-preview-iframe" style="width:100%;min-height:600px;border:none;" sandbox="allow-scripts"></iframe>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="file-view-render-container">
|
||||
<div class="file-view-raw-prompt tw-p-4">
|
||||
|
||||
Reference in New Issue
Block a user